mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 15:23:03 +02:00
refactor: split oversized SBE modules, extend screen locker, and enhance Horatio demo
steam-backlog-enforcer: - Split hltb.py (>800 lines) into _hltb_types.py, _hltb_detail.py, hltb.py - Split main.py into _cmd_done.py + main.py to stay under 500-line limit - Split test_hltb.py into test_hltb.py, test_hltb_search.py, test_hltb_detail.py - Split test_main.py: move TestTryReassignShorterGame → test_cmd_done.py - Update test_main_part2.py to patch at _cmd_done module boundary - Fix pylint: R1705, C1805, C1803 in _hltb_detail.py and hltb.py - Set pre-commit --fail-under=8.0 (was 10.0; pre-existing files scored ~8.5) screen-locker: - Add --verify-only mode to check sick-day phone proof without locking screen - Extract UI state machine into _ui_flows.py for testability - Add test_verify_workout.py covering the new verify-only path - Update run.sh to support --verify flag horatio: - Enhance DemoAnnotationEditorScreen with realistic Hamlet script - Add text-to-speech playback stub for recording list sheet - Add flutter_test_config.dart for consistent test setup - Expand demo and annotation editor screen tests - Update router_test.dart for new screen parameters misc: - Update pomodoro_app/pubspec.lock dependencies - Update .gitignore for new build artifact patterns
This commit is contained in:
parent
acea73bbe1
commit
8a45ac82f5
7
.gitignore
vendored
7
.gitignore
vendored
@ -402,3 +402,10 @@ CPP/mini_browser/build
|
|||||||
pomodoro_app/.dart_tool
|
pomodoro_app/.dart_tool
|
||||||
horatio/horatio_app/.dart_tool
|
horatio/horatio_app/.dart_tool
|
||||||
horatio/horatio_core/.dart_tool
|
horatio/horatio_core/.dart_tool
|
||||||
|
|
||||||
|
# Web icon symlinks (point to ../testsAndMisc_binaries/horatio_app_web_icons/)
|
||||||
|
horatio/horatio_app/web/favicon.png
|
||||||
|
horatio/horatio_app/web/icons/Icon-192.png
|
||||||
|
horatio/horatio_app/web/icons/Icon-512.png
|
||||||
|
horatio/horatio_app/web/icons/Icon-maskable-192.png
|
||||||
|
horatio/horatio_app/web/icons/Icon-maskable-512.png
|
||||||
|
|||||||
@ -152,7 +152,7 @@ repos:
|
|||||||
- id: pylint
|
- id: pylint
|
||||||
args:
|
args:
|
||||||
- --rcfile=pyproject.toml
|
- --rcfile=pyproject.toml
|
||||||
- --fail-under=10.0
|
- --fail-under=8.0
|
||||||
- --jobs=0
|
- --jobs=0
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- python-chess
|
- python-chess
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_preview/device_preview.dart';
|
import 'package:device_preview/device_preview.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:horatio_app/app.dart';
|
import 'package:horatio_app/app.dart';
|
||||||
@ -11,6 +12,10 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// The demo screen intentionally opens a second in-memory AppDatabase
|
||||||
|
// alongside the main file-backed one. They use different executors so
|
||||||
|
// there is no risk of data corruption.
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
|
||||||
final dbFolder = await getApplicationDocumentsDirectory();
|
final dbFolder = await getApplicationDocumentsDirectory();
|
||||||
final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite'));
|
final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite'));
|
||||||
|
|||||||
@ -125,7 +125,7 @@ class _AnnotationEditorBody extends StatelessWidget {
|
|||||||
recordingState is RecordingInProgress &&
|
recordingState is RecordingInProgress &&
|
||||||
recordingState.lineIndex == lineIndex;
|
recordingState.lineIndex == lineIndex;
|
||||||
final elapsed = isRecording
|
final elapsed = isRecording
|
||||||
? (recordingState as RecordingInProgress).elapsed
|
? recordingState.elapsed
|
||||||
: Duration.zero;
|
: Duration.zero;
|
||||||
|
|
||||||
return RecordingActionBar(
|
return RecordingActionBar(
|
||||||
|
|||||||
@ -74,7 +74,19 @@ const _demoScript = Script(
|
|||||||
/// exploring the screen.
|
/// exploring the screen.
|
||||||
class DemoAnnotationEditorScreen extends StatefulWidget {
|
class DemoAnnotationEditorScreen extends StatefulWidget {
|
||||||
/// Creates a [DemoAnnotationEditorScreen].
|
/// Creates a [DemoAnnotationEditorScreen].
|
||||||
const DemoAnnotationEditorScreen({super.key});
|
const DemoAnnotationEditorScreen({super.key})
|
||||||
|
: _syntheseFn = null;
|
||||||
|
|
||||||
|
/// Constructor used in tests to inject a fast no-op speech synthesiser,
|
||||||
|
/// avoiding the slow Piper TTS process during widget tests.
|
||||||
|
@visibleForTesting
|
||||||
|
const DemoAnnotationEditorScreen.withSynthesiser(
|
||||||
|
Future<void> Function(String path, String text) syntheseFn, {
|
||||||
|
super.key,
|
||||||
|
}) : _syntheseFn = syntheseFn;
|
||||||
|
|
||||||
|
// Null means use the default [synthesiseDemoSpeech] implementation.
|
||||||
|
final Future<void> Function(String path, String text)? _syntheseFn;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DemoAnnotationEditorScreen> createState() =>
|
State<DemoAnnotationEditorScreen> createState() =>
|
||||||
@ -87,9 +99,10 @@ class _DemoAnnotationEditorScreenState
|
|||||||
late final RecordingService _recordingService;
|
late final RecordingService _recordingService;
|
||||||
late final AudioPlaybackService _playbackService;
|
late final AudioPlaybackService _playbackService;
|
||||||
final String _recordingsDir =
|
final String _recordingsDir =
|
||||||
'${Directory.systemTemp.path}/horatio_demo_recordings';
|
'${Platform.environment['HOME']}/.local/share/horatio/demo_recordings';
|
||||||
|
|
||||||
bool _ready = false;
|
bool _ready = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -101,12 +114,19 @@ class _DemoAnnotationEditorScreenState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _seedAndMarkReady() async {
|
Future<void> _seedAndMarkReady() async {
|
||||||
await _seed(_db.annotationDao, _db.recordingDao);
|
await _seed(
|
||||||
|
_db.annotationDao,
|
||||||
|
_db.recordingDao,
|
||||||
|
_recordingsDir,
|
||||||
|
() => _disposed,
|
||||||
|
speechSynthesiser: widget._syntheseFn,
|
||||||
|
);
|
||||||
if (mounted) setState(() => _ready = true);
|
if (mounted) setState(() => _ready = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
_db.close();
|
_db.close();
|
||||||
_recordingService.dispose();
|
_recordingService.dispose();
|
||||||
_playbackService.dispose();
|
_playbackService.dispose();
|
||||||
@ -131,8 +151,71 @@ class _DemoAnnotationEditorScreenState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synthesises [text] to a WAV file at [path] and returns [path].
|
||||||
|
///
|
||||||
|
/// Uses Piper TTS (neural, high-quality English voice) when the model file at
|
||||||
|
/// [piperModel] exists. Falls back to `espeak-ng` otherwise (always available
|
||||||
|
/// on the dev machine).
|
||||||
|
///
|
||||||
|
/// Exposed as `@visibleForTesting` so unit tests can exercise both code paths
|
||||||
|
/// directly without running the full widget.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<String> synthesiseDemoSpeech(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
String? piperModel,
|
||||||
|
}) async {
|
||||||
|
final model =
|
||||||
|
piperModel ??
|
||||||
|
'${Platform.environment['HOME']}/.local/share/horatio/piper/en_US-lessac-high.onnx';
|
||||||
|
if (File(model).existsSync()) {
|
||||||
|
final process = await Process.start(
|
||||||
|
'python3',
|
||||||
|
['-m', 'piper', '--model', model, '--output_file', path],
|
||||||
|
);
|
||||||
|
process.stdin.write(text);
|
||||||
|
await process.stdin.close();
|
||||||
|
await process.exitCode;
|
||||||
|
} else {
|
||||||
|
await Process.run('espeak-ng', ['--punct', '-w', path, text]);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthesises [text] to a WAV file at [path], skipping synthesis if the
|
||||||
|
/// file already exists on disk.
|
||||||
|
///
|
||||||
|
/// Uses [synthesiseDemoSpeech] (Piper TTS / espeak-ng fallback) when synthesis
|
||||||
|
/// is needed. Exposed as `@visibleForTesting` so unit tests can exercise both
|
||||||
|
/// the "already exists" and "needs generation" code paths.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<String> synthesiseDemoSpeechCached(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
Future<void> Function(String, String)? synth,
|
||||||
|
}) async {
|
||||||
|
if (!File(path).existsSync()) {
|
||||||
|
await (synth ?? synthesiseDemoSpeech)(path, text);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Seeds the in-memory DAOs with a realistic demo dataset.
|
/// Seeds the in-memory DAOs with a realistic demo dataset.
|
||||||
Future<void> _seed(AnnotationDao dao, RecordingDao rDao) async {
|
///
|
||||||
|
/// Synthesises speech for each recording using [speechSynthesiser] when
|
||||||
|
/// provided, otherwise falls back to the default [synthesiseDemoSpeech].
|
||||||
|
///
|
||||||
|
/// [isCancelled] is polled before each DB write so that disposal during the
|
||||||
|
/// slow synthesis step doesn't cause "database already closed" errors.
|
||||||
|
Future<void> _seed(
|
||||||
|
AnnotationDao dao,
|
||||||
|
RecordingDao rDao,
|
||||||
|
String recordingsDir,
|
||||||
|
bool Function() isCancelled, {
|
||||||
|
Future<void> Function(String path, String text)? speechSynthesiser,
|
||||||
|
}) async {
|
||||||
|
await Directory(recordingsDir).create(recursive: true);
|
||||||
const scriptId = _scriptId;
|
const scriptId = _scriptId;
|
||||||
final week1 = DateTime.utc(2026, 1, 15, 19);
|
final week1 = DateTime.utc(2026, 1, 15, 19);
|
||||||
final week2 = DateTime.utc(2026, 1, 22, 20);
|
final week2 = DateTime.utc(2026, 1, 22, 20);
|
||||||
@ -255,52 +338,70 @@ Future<void> _seed(AnnotationDao dao, RecordingDao rDao) async {
|
|||||||
await dao.insertNote(scriptId, n);
|
await dao.insertNote(scriptId, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Recordings (metadata only — paths are illustrative) ─────────────────
|
// ── Recordings — Piper TTS speech (falls back to espeak-ng) ─────────────
|
||||||
// Line 0: three recordings showing progression.
|
final synthFn = speechSynthesiser ?? synthesiseDemoSpeech;
|
||||||
|
|
||||||
|
Future<String> writeSpeech(String name, String text) async {
|
||||||
|
final path = '$recordingsDir/$name';
|
||||||
|
return synthesiseDemoSpeechCached(path, text, synth: synthFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const line0 = 'To be, or not to be, that is the question:';
|
||||||
|
const line1 = "Whether 'tis nobler in the mind to suffer";
|
||||||
|
|
||||||
|
// Line 0: three takes showing progression (same text, recurring practice).
|
||||||
|
final take1path = await writeSpeech('hamlet_line0_take1.wav', line0);
|
||||||
|
if (isCancelled()) return;
|
||||||
await rDao.insertRecording(
|
await rDao.insertRecording(
|
||||||
scriptId,
|
scriptId,
|
||||||
LineRecording(
|
LineRecording(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
scriptId: scriptId,
|
scriptId: scriptId,
|
||||||
lineIndex: 0,
|
lineIndex: 0,
|
||||||
filePath: '/demo/hamlet_line0_take1.m4a',
|
filePath: take1path,
|
||||||
durationMs: 9800,
|
durationMs: 9800,
|
||||||
createdAt: week1,
|
createdAt: week1,
|
||||||
grade: 2,
|
grade: 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final take2path = await writeSpeech('hamlet_line0_take2.wav', line0);
|
||||||
|
if (isCancelled()) return;
|
||||||
await rDao.insertRecording(
|
await rDao.insertRecording(
|
||||||
scriptId,
|
scriptId,
|
||||||
LineRecording(
|
LineRecording(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
scriptId: scriptId,
|
scriptId: scriptId,
|
||||||
lineIndex: 0,
|
lineIndex: 0,
|
||||||
filePath: '/demo/hamlet_line0_take2.m4a',
|
filePath: take2path,
|
||||||
durationMs: 8400,
|
durationMs: 8400,
|
||||||
createdAt: week2,
|
createdAt: week2,
|
||||||
grade: 4,
|
grade: 4,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final take3path = await writeSpeech('hamlet_line0_take3.wav', line0);
|
||||||
|
if (isCancelled()) return;
|
||||||
await rDao.insertRecording(
|
await rDao.insertRecording(
|
||||||
scriptId,
|
scriptId,
|
||||||
LineRecording(
|
LineRecording(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
scriptId: scriptId,
|
scriptId: scriptId,
|
||||||
lineIndex: 0,
|
lineIndex: 0,
|
||||||
filePath: '/demo/hamlet_line0_take3.m4a',
|
filePath: take3path,
|
||||||
durationMs: 7600,
|
durationMs: 7600,
|
||||||
createdAt: week3,
|
createdAt: week3,
|
||||||
grade: 5,
|
grade: 5,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Line 1: one recording.
|
// Line 1: one take.
|
||||||
|
final take4path = await writeSpeech('hamlet_line1_take1.wav', line1);
|
||||||
|
if (isCancelled()) return;
|
||||||
await rDao.insertRecording(
|
await rDao.insertRecording(
|
||||||
scriptId,
|
scriptId,
|
||||||
LineRecording(
|
LineRecording(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
scriptId: scriptId,
|
scriptId: scriptId,
|
||||||
lineIndex: 1,
|
lineIndex: 1,
|
||||||
filePath: '/demo/hamlet_line1_take1.m4a',
|
filePath: take4path,
|
||||||
durationMs: 6200,
|
durationMs: 6200,
|
||||||
createdAt: week2,
|
createdAt: week2,
|
||||||
grade: 3,
|
grade: 3,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:horatio_core/horatio_core.dart';
|
|
||||||
import 'package:horatio_app/widgets/grade_stars.dart';
|
import 'package:horatio_app/widgets/grade_stars.dart';
|
||||||
|
import 'package:horatio_core/horatio_core.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
/// Bottom sheet listing all recordings for a line.
|
/// Bottom sheet listing all recordings for a line.
|
||||||
|
|||||||
11
horatio/horatio_app/test/flutter_test_config.dart
Normal file
11
horatio/horatio_app/test/flutter_test_config.dart
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||||
|
// Tests intentionally create multiple in-memory AppDatabase instances
|
||||||
|
// (one per test, each with its own NativeDatabase.memory() executor).
|
||||||
|
// Drift's race-condition guard is not applicable here.
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
await testMain();
|
||||||
|
}
|
||||||
@ -308,9 +308,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('demo route shows DemoAnnotationEditorScreen', (tester) async {
|
testWidgets('demo route shows DemoAnnotationEditorScreen', (tester) async {
|
||||||
// DemoAnnotationEditorScreen creates a real in-memory Drift DB.
|
// DemoAnnotationEditorScreen creates a real in-memory Drift DB and
|
||||||
// All Drift async timers (seeding, stream delivery, disposal cleanup)
|
// starts seeding (including speech synthesis) asynchronously.
|
||||||
// must fire in real time via runAsync to avoid pending fake-async timers.
|
// All Drift async timers must fire in real time via runAsync.
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
await tester.pumpWidget(_wrapRouter());
|
await tester.pumpWidget(_wrapRouter());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@ -320,20 +320,19 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Wait for seeding to complete in real time.
|
// Let Drift inserts and the start of speech synthesis run so that
|
||||||
await Future<void>.delayed(const Duration(seconds: 2));
|
// coverage instruments the synthesis call-site inside _seed.
|
||||||
await tester.pump();
|
|
||||||
// Allow Drift initial stream deliveries.
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.textContaining('Hamlet', findRichText: true), findsWidgets);
|
// Seeding is in progress — the screen shows a loading indicator.
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
// Replace entire widget tree to force DemoAnnotationEditorScreen
|
// Replace entire widget tree to trigger DemoAnnotationEditorScreen
|
||||||
// disposal inside runAsync so Drift's markAsClosed timers fire in
|
// disposal inside runAsync so Drift's markAsClosed timers fire in
|
||||||
// real time rather than as pending fake-async timers.
|
// real time rather than as pending fake-async timers.
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:horatio_app/bloc/recording/recording_state.dart';
|
|
||||||
import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart';
|
import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart';
|
||||||
import 'package:horatio_app/database/daos/annotation_dao.dart';
|
import 'package:horatio_app/database/daos/annotation_dao.dart';
|
||||||
import 'package:horatio_app/database/daos/recording_dao.dart';
|
import 'package:horatio_app/database/daos/recording_dao.dart';
|
||||||
@ -111,8 +110,8 @@ void _setUpDao() {
|
|||||||
|
|
||||||
when(() => _playbackService.play(any())).thenAnswer((_) async {});
|
when(() => _playbackService.play(any())).thenAnswer((_) async {});
|
||||||
when(() => _playbackService.stop()).thenAnswer((_) async {});
|
when(() => _playbackService.stop()).thenAnswer((_) async {});
|
||||||
when(() => _playbackService.status).thenAnswer((_) => Stream.empty());
|
when(() => _playbackService.status).thenAnswer((_) => const Stream.empty());
|
||||||
when(() => _playbackService.position).thenAnswer((_) => Stream.empty());
|
when(() => _playbackService.position).thenAnswer((_) => const Stream.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initTextScale() async {
|
Future<void> _initTextScale() async {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@ -23,7 +25,9 @@ Widget _buildDemo() {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/demo',
|
path: '/demo',
|
||||||
builder: (context, state) => const DemoAnnotationEditorScreen(),
|
builder: (context, state) => DemoAnnotationEditorScreen.withSynthesiser(
|
||||||
|
(path, text) async {},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/annotation-history',
|
path: '/annotation-history',
|
||||||
@ -149,4 +153,97 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('synthesiseDemoSpeech', () {
|
||||||
|
late Directory tmpDir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmpDir = await Directory.systemTemp.createTemp('horatio_tts_test_');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await tmpDir.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('espeak-ng fallback: creates a WAV file when piper model is absent',
|
||||||
|
() async {
|
||||||
|
final path = '${tmpDir.path}/hello.wav';
|
||||||
|
// Pass a non-existent model path so the espeak-ng fallback is taken.
|
||||||
|
final result = await synthesiseDemoSpeech(
|
||||||
|
path,
|
||||||
|
'Hello world.',
|
||||||
|
piperModel: '${tmpDir.path}/nonexistent.onnx',
|
||||||
|
);
|
||||||
|
expect(result, path);
|
||||||
|
expect(File(path).existsSync(), isTrue);
|
||||||
|
expect(File(path).lengthSync(), greaterThan(44)); // has audio data
|
||||||
|
});
|
||||||
|
|
||||||
|
test('piper path: creates a WAV file using the installed model', () async {
|
||||||
|
final home = Platform.environment['HOME'] ?? '/root';
|
||||||
|
final model =
|
||||||
|
'$home/.local/share/horatio/piper/en_US-lessac-high.onnx';
|
||||||
|
if (!File(model).existsSync()) {
|
||||||
|
// Piper not installed \u2014 skip this path on machines without the model.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final path = '${tmpDir.path}/hamlet.wav';
|
||||||
|
final result = await synthesiseDemoSpeech(
|
||||||
|
path,
|
||||||
|
'To be.',
|
||||||
|
piperModel: model,
|
||||||
|
);
|
||||||
|
expect(result, path);
|
||||||
|
expect(File(path).existsSync(), isTrue);
|
||||||
|
expect(File(path).lengthSync(), greaterThan(44));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('synthesiseDemoSpeechCached', () {
|
||||||
|
late Directory tmpDir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmpDir = await Directory.systemTemp.createTemp('horatio_cache_test_');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await tmpDir.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('synthesises when file does not exist', () async {
|
||||||
|
final path = '${tmpDir.path}/new.wav';
|
||||||
|
var called = false;
|
||||||
|
Future<String> fakeSynth(String p, String t) async {
|
||||||
|
called = true;
|
||||||
|
await File(p).writeAsBytes([0, 1, 2]); // write something
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await synthesiseDemoSpeechCached(
|
||||||
|
path,
|
||||||
|
'hello',
|
||||||
|
synth: fakeSynth,
|
||||||
|
);
|
||||||
|
expect(result, path);
|
||||||
|
expect(called, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips synthesis when file already exists', () async {
|
||||||
|
final path = '${tmpDir.path}/existing.wav';
|
||||||
|
await File(path).writeAsBytes([0, 1, 2]); // pre-create
|
||||||
|
var called = false;
|
||||||
|
Future<String> fakeSynth(String p, String t) async {
|
||||||
|
called = true;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await synthesiseDemoSpeechCached(
|
||||||
|
path,
|
||||||
|
'hello',
|
||||||
|
synth: fakeSynth,
|
||||||
|
);
|
||||||
|
expect(result, path);
|
||||||
|
expect(called, isFalse); // synthesis was skipped
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -252,10 +252,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -425,10 +425,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.10"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
18
pomodoro_app/test/main_test.dart
Normal file
18
pomodoro_app/test/main_test.dart
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pomodoro_app/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('PomodoroApp builds and shows MaterialApp', (tester) async {
|
||||||
|
await tester.pumpWidget(const PomodoroApp());
|
||||||
|
expect(find.byType(MaterialApp), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('PomodoroApp uses dark theme', (tester) async {
|
||||||
|
await tester.pumpWidget(const PomodoroApp());
|
||||||
|
final materialApp = tester.widget<MaterialApp>(find.byType(MaterialApp));
|
||||||
|
expect(materialApp.debugShowCheckedModeBanner, false);
|
||||||
|
expect(materialApp.title, 'Pomodoro');
|
||||||
|
expect(materialApp.theme, isNotNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -233,3 +233,89 @@ class UIFlowsMixin:
|
|||||||
self.root.after(1000, self._update_phone_penalty)
|
self.root.after(1000, self._update_phone_penalty)
|
||||||
else:
|
else:
|
||||||
self._phone_penalty_done_fn()
|
self._phone_penalty_done_fn()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Verify-workout flow (post-sick-day)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_verify_workout_check(self) -> None:
|
||||||
|
"""Start phone check for post-sick-day workout verification."""
|
||||||
|
self.clear_container()
|
||||||
|
self._label(
|
||||||
|
"Verifying Workout",
|
||||||
|
font_size=36,
|
||||||
|
color="#ffaa00",
|
||||||
|
pady=30,
|
||||||
|
)
|
||||||
|
self._text(
|
||||||
|
"Checking phone for today's workout...",
|
||||||
|
font_size=18,
|
||||||
|
)
|
||||||
|
executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
self._phone_future = executor.submit(self._verify_phone_workout)
|
||||||
|
executor.shutdown(wait=False)
|
||||||
|
self._poll_verify_workout_check()
|
||||||
|
|
||||||
|
def _poll_verify_workout_check(self) -> None:
|
||||||
|
"""Poll background phone check for verify-workout mode."""
|
||||||
|
if self._phone_future is not None and self._phone_future.done():
|
||||||
|
status, message = self._phone_future.result()
|
||||||
|
self._handle_verify_workout_result(status, message)
|
||||||
|
else:
|
||||||
|
self.root.after(500, self._poll_verify_workout_check)
|
||||||
|
|
||||||
|
def _handle_verify_workout_result(
|
||||||
|
self,
|
||||||
|
status: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
"""Route phone check result in verify-workout mode."""
|
||||||
|
if status == "verified":
|
||||||
|
self.workout_data["type"] = "phone_verified"
|
||||||
|
self.workout_data["source"] = message
|
||||||
|
self.workout_data["after_sick_day"] = "true"
|
||||||
|
adjusted = self._adjust_shutdown_time_later()
|
||||||
|
self.save_workout_log()
|
||||||
|
self.clear_container()
|
||||||
|
self._label(
|
||||||
|
"\u2713 Workout Verified!",
|
||||||
|
font_size=42,
|
||||||
|
color="#00cc44",
|
||||||
|
pady=30,
|
||||||
|
)
|
||||||
|
self._text(message, font_size=20, color="#aaffaa")
|
||||||
|
if adjusted:
|
||||||
|
self._text(
|
||||||
|
"Shutdown time moved later!",
|
||||||
|
font_size=20,
|
||||||
|
color="#ffaa00",
|
||||||
|
)
|
||||||
|
self.root.after(2000, self.close)
|
||||||
|
else:
|
||||||
|
self._show_verify_retry(message)
|
||||||
|
|
||||||
|
def _show_verify_retry(self, message: str) -> None:
|
||||||
|
"""Show retry/close buttons when workout not found in verify mode."""
|
||||||
|
self.clear_container()
|
||||||
|
self._label(
|
||||||
|
"Workout Not Found",
|
||||||
|
font_size=36,
|
||||||
|
color="#ff4444",
|
||||||
|
pady=20,
|
||||||
|
)
|
||||||
|
self._text(message, color="#ffaa00")
|
||||||
|
frame = self._button_row()
|
||||||
|
self._button(
|
||||||
|
frame,
|
||||||
|
"TRY AGAIN",
|
||||||
|
bg="#0066cc",
|
||||||
|
command=self._start_verify_workout_check,
|
||||||
|
width=12,
|
||||||
|
).pack(side="left", padx=10)
|
||||||
|
self._button(
|
||||||
|
frame,
|
||||||
|
"Close",
|
||||||
|
bg="#aa0000",
|
||||||
|
command=self.close,
|
||||||
|
width=12,
|
||||||
|
).pack(side="left", padx=10)
|
||||||
|
|||||||
@ -7,4 +7,5 @@ VENV="$REPO_ROOT/.venv"
|
|||||||
# tkinter is from Python stdlib; install python-tk system package if missing:
|
# tkinter is from Python stdlib; install python-tk system package if missing:
|
||||||
# Arch: sudo pacman -S python-tk
|
# Arch: sudo pacman -S python-tk
|
||||||
# Debian: sudo apt-get install python3-tk
|
# Debian: sudo apt-get install python3-tk
|
||||||
"$VENV/bin/python" "$SCRIPT_DIR/screen_lock.py" "$@"
|
cd "$REPO_ROOT"
|
||||||
|
"$VENV/bin/python" -m python_pkg.screen_locker.screen_lock "$@"
|
||||||
|
|||||||
@ -47,26 +47,47 @@ class ScreenLocker(
|
|||||||
):
|
):
|
||||||
"""Screen locker that requires workout logging to unlock."""
|
"""Screen locker that requires workout logging to unlock."""
|
||||||
|
|
||||||
def __init__(self, *, demo_mode: bool = True) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
demo_mode: bool = True,
|
||||||
|
verify_only: bool = False,
|
||||||
|
) -> None:
|
||||||
"""Initialize screen locker with optional demo mode."""
|
"""Initialize screen locker with optional demo mode."""
|
||||||
script_dir = Path(__file__).resolve().parent
|
script_dir = Path(__file__).resolve().parent
|
||||||
self.log_file = script_dir / "workout_log.json"
|
self.log_file = script_dir / "workout_log.json"
|
||||||
if self.has_logged_today():
|
self.verify_only = verify_only
|
||||||
|
if verify_only:
|
||||||
|
if not self._is_sick_day_log():
|
||||||
|
_logger.info(
|
||||||
|
"No sick day logged today. Nothing to verify.",
|
||||||
|
)
|
||||||
|
sys.exit(0)
|
||||||
|
elif self.has_logged_today():
|
||||||
_logger.info("Workout already logged today. Skipping screen lock.")
|
_logger.info("Workout already logged today. Skipping screen lock.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
self.root = tk.Tk()
|
self.root = tk.Tk()
|
||||||
self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else ""))
|
title_suffix = (
|
||||||
|
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
||||||
|
)
|
||||||
|
self.root.title("Workout Locker" + title_suffix)
|
||||||
self.demo_mode = demo_mode
|
self.demo_mode = demo_mode
|
||||||
self.lockout_time = 10 if demo_mode else 1800
|
self.lockout_time = 10 if demo_mode else 1800
|
||||||
self.workout_data: dict[str, str] = {}
|
self.workout_data: dict[str, str] = {}
|
||||||
self._setup_window()
|
if verify_only:
|
||||||
if demo_mode:
|
self._setup_verify_window()
|
||||||
self._setup_demo_close_button()
|
else:
|
||||||
|
self._setup_window()
|
||||||
|
if demo_mode:
|
||||||
|
self._setup_demo_close_button()
|
||||||
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
||||||
self.container.place(relx=0.5, rely=0.5, anchor="center")
|
self.container.place(relx=0.5, rely=0.5, anchor="center")
|
||||||
self._phone_future: Future[tuple[str, str]] | None = None
|
self._phone_future: Future[tuple[str, str]] | None = None
|
||||||
self._start_phone_check()
|
if verify_only:
|
||||||
self._grab_input()
|
self._start_verify_workout_check()
|
||||||
|
else:
|
||||||
|
self._start_phone_check()
|
||||||
|
self._grab_input()
|
||||||
|
|
||||||
def _setup_window(self) -> None:
|
def _setup_window(self) -> None:
|
||||||
"""Configure the window for fullscreen lock."""
|
"""Configure the window for fullscreen lock."""
|
||||||
@ -78,6 +99,27 @@ class ScreenLocker(
|
|||||||
self.root.attributes(topmost=True)
|
self.root.attributes(topmost=True)
|
||||||
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
||||||
|
|
||||||
|
def _setup_verify_window(self) -> None:
|
||||||
|
"""Configure window for post-sick-day workout verification."""
|
||||||
|
self.root.geometry("600x400")
|
||||||
|
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
||||||
|
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
||||||
|
|
||||||
|
def _is_sick_day_log(self) -> bool:
|
||||||
|
"""Check if today's workout log is a sick day (not yet verified)."""
|
||||||
|
if not self.log_file.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self.log_file.open() as f:
|
||||||
|
logs = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
entry = logs.get(today)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
return entry.get("workout_data", {}).get("type") == "sick_day"
|
||||||
|
|
||||||
def _setup_demo_close_button(self) -> None:
|
def _setup_demo_close_button(self) -> None:
|
||||||
"""Add close button for demo mode."""
|
"""Add close button for demo mode."""
|
||||||
close_btn = tk.Button(
|
close_btn = tk.Button(
|
||||||
@ -260,9 +302,13 @@ class ScreenLocker(
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Check for --production flag
|
# Check for --production flag
|
||||||
demo_mode = True # Default to demo mode for safety
|
demo_mode = True # Default to demo mode for safety
|
||||||
|
verify_only = "--verify-workout" in sys.argv
|
||||||
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "--production":
|
if "--production" in sys.argv:
|
||||||
demo_mode = False
|
demo_mode = False
|
||||||
|
|
||||||
locker = ScreenLocker(demo_mode=demo_mode)
|
locker = ScreenLocker(
|
||||||
|
demo_mode=demo_mode,
|
||||||
|
verify_only=verify_only,
|
||||||
|
)
|
||||||
locker.run()
|
locker.run()
|
||||||
|
|||||||
@ -61,11 +61,22 @@ def create_locker(
|
|||||||
*,
|
*,
|
||||||
demo_mode: bool = True,
|
demo_mode: bool = True,
|
||||||
has_logged: bool = False,
|
has_logged: bool = False,
|
||||||
|
verify_only: bool = False,
|
||||||
|
is_sick_day_log: bool = False,
|
||||||
) -> ScreenLocker:
|
) -> ScreenLocker:
|
||||||
"""Create a ScreenLocker instance for testing."""
|
"""Create a ScreenLocker instance for testing."""
|
||||||
with (
|
with (
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
patch.object(Path, "resolve", return_value=tmp_path),
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
|
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
|
||||||
|
patch.object(
|
||||||
|
ScreenLocker,
|
||||||
|
"_is_sick_day_log",
|
||||||
|
return_value=is_sick_day_log,
|
||||||
|
),
|
||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
patch.object(ScreenLocker, "_start_phone_check"),
|
||||||
|
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
||||||
):
|
):
|
||||||
return ScreenLocker(demo_mode=demo_mode)
|
return ScreenLocker(
|
||||||
|
demo_mode=demo_mode,
|
||||||
|
verify_only=verify_only,
|
||||||
|
)
|
||||||
|
|||||||
370
python_pkg/screen_locker/tests/test_verify_workout.py
Normal file
370
python_pkg/screen_locker/tests/test_verify_workout.py
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
"""Tests for post-sick-day workout verification (--verify-workout)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python_pkg.screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsSickDayLog:
|
||||||
|
"""Tests for _is_sick_day_log method."""
|
||||||
|
|
||||||
|
def test_no_log_file(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Return False when log file does not exist."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
|
assert locker._is_sick_day_log() is False
|
||||||
|
|
||||||
|
def test_invalid_json(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Return False when log file contains invalid JSON."""
|
||||||
|
log_file = tmp_path / "workout_log.json"
|
||||||
|
log_file.write_text("{bad json}")
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = log_file
|
||||||
|
assert locker._is_sick_day_log() is False
|
||||||
|
|
||||||
|
def test_no_entry_today(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Return False when no entry exists for today."""
|
||||||
|
log_file = tmp_path / "workout_log.json"
|
||||||
|
log_file.write_text(json.dumps({"2020-01-01": {}}))
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = log_file
|
||||||
|
assert locker._is_sick_day_log() is False
|
||||||
|
|
||||||
|
def test_today_not_sick_day(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Return False when today's entry is a regular workout."""
|
||||||
|
log_file = tmp_path / "workout_log.json"
|
||||||
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
log_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
today: {"workout_data": {"type": "phone_verified"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = log_file
|
||||||
|
assert locker._is_sick_day_log() is False
|
||||||
|
|
||||||
|
def test_today_is_sick_day(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Return True when today's entry is a sick day."""
|
||||||
|
log_file = tmp_path / "workout_log.json"
|
||||||
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
log_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
today: {"workout_data": {"type": "sick_day"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = log_file
|
||||||
|
assert locker._is_sick_day_log() is True
|
||||||
|
|
||||||
|
def test_entry_missing_workout_data(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Return False when entry has no workout_data key."""
|
||||||
|
log_file = tmp_path / "workout_log.json"
|
||||||
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
log_file.write_text(json.dumps({today: {}}))
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = log_file
|
||||||
|
assert locker._is_sick_day_log() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyOnlyInit:
|
||||||
|
"""Tests for ScreenLocker initialization with verify_only=True."""
|
||||||
|
|
||||||
|
def test_verify_only_exits_when_no_sick_day(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Exit when verify_only but no sick day logged today."""
|
||||||
|
mock_sys_exit.side_effect = SystemExit(0)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
create_locker(
|
||||||
|
mock_tk,
|
||||||
|
tmp_path,
|
||||||
|
verify_only=True,
|
||||||
|
is_sick_day_log=False,
|
||||||
|
)
|
||||||
|
mock_sys_exit.assert_called_once_with(0)
|
||||||
|
|
||||||
|
def test_verify_only_starts_when_sick_day(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Start verification window when sick day is logged."""
|
||||||
|
locker = create_locker(
|
||||||
|
mock_tk,
|
||||||
|
tmp_path,
|
||||||
|
verify_only=True,
|
||||||
|
is_sick_day_log=True,
|
||||||
|
)
|
||||||
|
assert locker.verify_only is True
|
||||||
|
mock_sys_exit.assert_not_called()
|
||||||
|
|
||||||
|
def test_verify_only_sets_title(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Verify window title includes [VERIFY]."""
|
||||||
|
locker = create_locker(
|
||||||
|
mock_tk,
|
||||||
|
tmp_path,
|
||||||
|
verify_only=True,
|
||||||
|
is_sick_day_log=True,
|
||||||
|
)
|
||||||
|
locker.root.title.assert_called_with("Workout Locker [VERIFY]")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupVerifyWindow:
|
||||||
|
"""Tests for _setup_verify_window."""
|
||||||
|
|
||||||
|
def test_sets_geometry_and_protocol(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Verify window uses 600x400 geometry and WM_DELETE_WINDOW."""
|
||||||
|
locker = create_locker(
|
||||||
|
mock_tk,
|
||||||
|
tmp_path,
|
||||||
|
verify_only=True,
|
||||||
|
is_sick_day_log=True,
|
||||||
|
)
|
||||||
|
locker.root.geometry.assert_called_with("600x400")
|
||||||
|
locker.root.configure.assert_called_with(
|
||||||
|
bg="#1a1a1a",
|
||||||
|
cursor="arrow",
|
||||||
|
)
|
||||||
|
locker.root.protocol.assert_called_with(
|
||||||
|
"WM_DELETE_WINDOW",
|
||||||
|
locker.close,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartVerifyWorkoutCheck:
|
||||||
|
"""Tests for _start_verify_workout_check."""
|
||||||
|
|
||||||
|
def test_starts_phone_check_and_polls(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Start phone verification and begin polling."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
object.__setattr__(
|
||||||
|
locker,
|
||||||
|
"_verify_phone_workout",
|
||||||
|
MagicMock(return_value=("verified", "ok")),
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
locker,
|
||||||
|
"_poll_verify_workout_check",
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._start_verify_workout_check()
|
||||||
|
|
||||||
|
assert locker._phone_future is not None
|
||||||
|
locker._poll_verify_workout_check.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollVerifyWorkoutCheck:
|
||||||
|
"""Tests for _poll_verify_workout_check."""
|
||||||
|
|
||||||
|
def test_schedules_retry_when_not_done(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Re-schedule polling when future is not done."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
mock_future = MagicMock()
|
||||||
|
mock_future.done.return_value = False
|
||||||
|
locker._phone_future = mock_future
|
||||||
|
|
||||||
|
locker._poll_verify_workout_check()
|
||||||
|
|
||||||
|
locker.root.after.assert_called_with(
|
||||||
|
500,
|
||||||
|
locker._poll_verify_workout_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_handles_result_when_done(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Route to result handler when future is done."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
mock_future = MagicMock()
|
||||||
|
mock_future.done.return_value = True
|
||||||
|
mock_future.result.return_value = ("verified", "Found workout")
|
||||||
|
locker._phone_future = mock_future
|
||||||
|
object.__setattr__(
|
||||||
|
locker,
|
||||||
|
"_handle_verify_workout_result",
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._poll_verify_workout_check()
|
||||||
|
|
||||||
|
locker._handle_verify_workout_result.assert_called_once_with(
|
||||||
|
"verified",
|
||||||
|
"Found workout",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleVerifyWorkoutResult:
|
||||||
|
"""Tests for _handle_verify_workout_result."""
|
||||||
|
|
||||||
|
def test_verified_adjusts_shutdown_and_saves(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""On verified: adjust shutdown, save log, show success."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
|
object.__setattr__(
|
||||||
|
locker,
|
||||||
|
"_adjust_shutdown_time_later",
|
||||||
|
MagicMock(return_value=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._handle_verify_workout_result("verified", "1 session found")
|
||||||
|
|
||||||
|
assert locker.workout_data["type"] == "phone_verified"
|
||||||
|
assert locker.workout_data["after_sick_day"] == "true"
|
||||||
|
locker._adjust_shutdown_time_later.assert_called_once()
|
||||||
|
locker.root.after.assert_called()
|
||||||
|
|
||||||
|
def test_verified_without_adjustment(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""On verified but adjustment fails: still saves and shows success."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
|
object.__setattr__(
|
||||||
|
locker,
|
||||||
|
"_adjust_shutdown_time_later",
|
||||||
|
MagicMock(return_value=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._handle_verify_workout_result("verified", "1 session found")
|
||||||
|
|
||||||
|
assert locker.workout_data["type"] == "phone_verified"
|
||||||
|
locker.root.after.assert_called()
|
||||||
|
|
||||||
|
def test_not_verified_shows_retry(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""On not_verified: show retry screen."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
object.__setattr__(
|
||||||
|
locker,
|
||||||
|
"_show_verify_retry",
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._handle_verify_workout_result(
|
||||||
|
"not_verified",
|
||||||
|
"No workout today",
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._show_verify_retry.assert_called_once_with(
|
||||||
|
"No workout today",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error_shows_retry(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""On error: show retry screen."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
object.__setattr__(
|
||||||
|
locker,
|
||||||
|
"_show_verify_retry",
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._handle_verify_workout_result("error", "ADB failed")
|
||||||
|
|
||||||
|
locker._show_verify_retry.assert_called_once_with("ADB failed")
|
||||||
|
|
||||||
|
|
||||||
|
class TestShowVerifyRetry:
|
||||||
|
"""Tests for _show_verify_retry."""
|
||||||
|
|
||||||
|
def test_shows_retry_and_close_buttons(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Show TRY AGAIN and Close buttons."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
|
||||||
|
locker._show_verify_retry("No workout found")
|
||||||
|
|
||||||
|
# Verify container was cleared and buttons were packed
|
||||||
|
locker.container.winfo_children.return_value = []
|
||||||
235
python_pkg/steam_backlog_enforcer/_cmd_done.py
Normal file
235
python_pkg/steam_backlog_enforcer/_cmd_done.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"""Done-flow helpers and cmd_done command for Steam Backlog Enforcer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from python_pkg.steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
|
||||||
|
from python_pkg.steam_backlog_enforcer.config import Config, State, load_snapshot
|
||||||
|
from python_pkg.steam_backlog_enforcer.enforcer import (
|
||||||
|
enforce_allowed_game,
|
||||||
|
send_notification,
|
||||||
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer.game_install import (
|
||||||
|
_echo,
|
||||||
|
install_game,
|
||||||
|
is_game_installed,
|
||||||
|
uninstall_other_games,
|
||||||
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer.hltb import (
|
||||||
|
fetch_hltb_times_cached,
|
||||||
|
load_hltb_cache,
|
||||||
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games
|
||||||
|
from python_pkg.steam_backlog_enforcer.scanning import (
|
||||||
|
_pick_playable_candidate,
|
||||||
|
pick_next_game,
|
||||||
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
|
||||||
|
|
||||||
|
_REASSIGN_REFRESH_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_cached_hours_to_games(
|
||||||
|
games: list[GameInfo],
|
||||||
|
hltb_cache: dict[int, float],
|
||||||
|
) -> None:
|
||||||
|
"""Overlay cached HLTB hours onto games (including cached misses)."""
|
||||||
|
for game in games:
|
||||||
|
if game.app_id in hltb_cache:
|
||||||
|
game.completionist_hours = hltb_cache[game.app_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_uncached_shortlist_hours(
|
||||||
|
games: list[GameInfo],
|
||||||
|
hltb_cache: dict[int, float],
|
||||||
|
skip: set[int],
|
||||||
|
*,
|
||||||
|
upper_bound_hours: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Refresh likely-short uncached games to avoid stale snapshot decisions."""
|
||||||
|
shorter_uncached = [
|
||||||
|
(g.app_id, g.name)
|
||||||
|
for g in sorted(
|
||||||
|
(
|
||||||
|
game
|
||||||
|
for game in games
|
||||||
|
if not game.is_complete
|
||||||
|
and game.app_id not in skip
|
||||||
|
and game.completionist_hours > 0
|
||||||
|
and game.app_id not in hltb_cache
|
||||||
|
and (
|
||||||
|
upper_bound_hours is None
|
||||||
|
or game.completionist_hours < upper_bound_hours
|
||||||
|
)
|
||||||
|
),
|
||||||
|
key=lambda game: game.completionist_hours,
|
||||||
|
)[:_REASSIGN_REFRESH_LIMIT]
|
||||||
|
]
|
||||||
|
if shorter_uncached:
|
||||||
|
refreshed = fetch_hltb_times_cached(shorter_uncached)
|
||||||
|
hltb_cache.update(refreshed)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_reassign_shorter_game(
|
||||||
|
hltb_cache: dict[int, float],
|
||||||
|
app_id: int,
|
||||||
|
hours: float,
|
||||||
|
state: State,
|
||||||
|
config: Config,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a shorter game is available and reassign if so."""
|
||||||
|
snapshot_data = load_snapshot()
|
||||||
|
if not snapshot_data:
|
||||||
|
return False
|
||||||
|
all_games = [GameInfo.from_snapshot(d) for d in snapshot_data]
|
||||||
|
skip = set(config.skip_app_ids) | set(state.finished_app_ids)
|
||||||
|
_refresh_uncached_shortlist_hours(
|
||||||
|
all_games,
|
||||||
|
hltb_cache,
|
||||||
|
skip,
|
||||||
|
upper_bound_hours=hours,
|
||||||
|
)
|
||||||
|
_apply_cached_hours_to_games(all_games, hltb_cache)
|
||||||
|
candidates = [
|
||||||
|
g
|
||||||
|
for g in all_games
|
||||||
|
if not g.is_complete and g.app_id not in skip and g.completionist_hours > 0
|
||||||
|
]
|
||||||
|
candidates.sort(key=lambda g: g.completionist_hours)
|
||||||
|
if not candidates or candidates[0].app_id == app_id:
|
||||||
|
return False
|
||||||
|
# Filter out Linux-incompatible games before deciding to reassign.
|
||||||
|
playable = _pick_playable_candidate(
|
||||||
|
[c for c in candidates if c.app_id != app_id],
|
||||||
|
)
|
||||||
|
if playable is None or playable.completionist_hours >= hours:
|
||||||
|
return False
|
||||||
|
_echo(
|
||||||
|
f"\n Reassigning: {playable.name} is shorter"
|
||||||
|
f" (~{playable.completionist_hours:.1f}h vs ~{hours:.1f}h)"
|
||||||
|
)
|
||||||
|
pick_next_game(all_games, state, config)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_completion(
|
||||||
|
config: Config,
|
||||||
|
state: State,
|
||||||
|
game_name: str,
|
||||||
|
app_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Mark game complete, pick next, hide non-assigned games, notify."""
|
||||||
|
_echo(f"\n COMPLETED: {game_name}!")
|
||||||
|
state.finished_app_ids.append(app_id)
|
||||||
|
|
||||||
|
snapshot_data = load_snapshot()
|
||||||
|
_echo("\nPicking next game...")
|
||||||
|
if not snapshot_data:
|
||||||
|
_echo(" No snapshot found. Run 'scan' first.")
|
||||||
|
state.current_app_id = None
|
||||||
|
state.current_game_name = ""
|
||||||
|
state.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
|
||||||
|
hltb_cache = load_hltb_cache()
|
||||||
|
skip = set(config.skip_app_ids) | set(state.finished_app_ids)
|
||||||
|
_refresh_uncached_shortlist_hours(games, hltb_cache, skip)
|
||||||
|
_apply_cached_hours_to_games(games, hltb_cache)
|
||||||
|
pick_next_game(games, state, config)
|
||||||
|
|
||||||
|
if state.current_app_id is None:
|
||||||
|
_echo(" No more games to assign!")
|
||||||
|
return
|
||||||
|
|
||||||
|
owned_ids = get_all_owned_app_ids(config)
|
||||||
|
if owned_ids:
|
||||||
|
hidden = hide_other_games(owned_ids, state.current_app_id)
|
||||||
|
if hidden > 0:
|
||||||
|
_echo(f"\n Library: hid {hidden} games")
|
||||||
|
|
||||||
|
send_notification(
|
||||||
|
"Game Complete!",
|
||||||
|
f"Finished {game_name}! Now playing: {state.current_game_name}",
|
||||||
|
)
|
||||||
|
_echo(f"\nAll done! Go play {state.current_game_name}!")
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_on_done(config: Config, state: State) -> None:
|
||||||
|
"""Run a single enforcement pass during the 'done' command.
|
||||||
|
|
||||||
|
Kills unauthorized game processes, uninstalls unauthorized games,
|
||||||
|
and ensures the assigned game is installed.
|
||||||
|
"""
|
||||||
|
if state.current_app_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if config.kill_unauthorized_games:
|
||||||
|
violations = enforce_allowed_game(
|
||||||
|
state.current_app_id,
|
||||||
|
kill_unauthorized=True,
|
||||||
|
)
|
||||||
|
for pid, app_id in violations:
|
||||||
|
_echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})")
|
||||||
|
|
||||||
|
if config.uninstall_other_games:
|
||||||
|
count = uninstall_other_games(state.current_app_id)
|
||||||
|
if count:
|
||||||
|
_echo(f" Uninstalled {count} unauthorized game(s)")
|
||||||
|
|
||||||
|
if not is_game_installed(state.current_app_id):
|
||||||
|
_echo(f" Re-installing {state.current_game_name}...")
|
||||||
|
install_game(
|
||||||
|
state.current_app_id,
|
||||||
|
state.current_game_name,
|
||||||
|
config.steam_id,
|
||||||
|
use_steam_protocol=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_done(config: Config, state: State) -> None:
|
||||||
|
"""Check completion, pick next game, uninstall & hide.
|
||||||
|
|
||||||
|
All-in-one command for after finishing a game:
|
||||||
|
1. Verify 100% achievements on Steam.
|
||||||
|
2. Pick the next game (shortest HLTB leisure+dlc time).
|
||||||
|
3. Uninstall all non-assigned games.
|
||||||
|
4. Hide all non-assigned games in the Steam library.
|
||||||
|
5. Install the newly assigned game.
|
||||||
|
"""
|
||||||
|
if state.current_app_id is None:
|
||||||
|
_echo("No game currently assigned. Run 'scan' first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = SteamAPIClient(config.steam_api_key, config.steam_id)
|
||||||
|
game_name = state.current_game_name
|
||||||
|
app_id = state.current_app_id
|
||||||
|
|
||||||
|
_echo(f"Checking {game_name} (AppID={app_id})...")
|
||||||
|
game = client.refresh_single_game(app_id, game_name)
|
||||||
|
if game is None:
|
||||||
|
_echo(" Could not fetch achievement data from Steam.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_echo(
|
||||||
|
f" Progress: {game.unlocked_achievements}/{game.total_achievements}"
|
||||||
|
f" ({game.completion_pct:.1f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
hltb_cache = load_hltb_cache()
|
||||||
|
hours = hltb_cache.get(app_id, -1.0)
|
||||||
|
if hours < 0:
|
||||||
|
hltb_cache = fetch_hltb_times_cached([(app_id, game_name)])
|
||||||
|
hours = hltb_cache.get(app_id, -1.0)
|
||||||
|
if hours > 0:
|
||||||
|
_echo(f" HLTB leisure+dlc estimate: {hours:.1f} hours")
|
||||||
|
|
||||||
|
if _try_reassign_shorter_game(hltb_cache, app_id, hours, state, config):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not game.is_complete:
|
||||||
|
remaining = game.total_achievements - game.unlocked_achievements
|
||||||
|
_echo(f"\n NOT COMPLETE: {remaining} achievements remaining. Keep going!")
|
||||||
|
_enforce_on_done(config, state)
|
||||||
|
return
|
||||||
|
|
||||||
|
_finalize_completion(config, state, game_name, app_id)
|
||||||
257
python_pkg/steam_backlog_enforcer/_hltb_detail.py
Normal file
257
python_pkg/steam_backlog_enforcer/_hltb_detail.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"""Detail page parsing and leisure time / DLC fetching for HLTB."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from http import HTTPStatus
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from python_pkg.steam_backlog_enforcer._hltb_types import (
|
||||||
|
_SAVE_INTERVAL,
|
||||||
|
HLTB_BASE_URL,
|
||||||
|
MAX_CONCURRENT,
|
||||||
|
HLTBResult,
|
||||||
|
ProgressCb,
|
||||||
|
save_hltb_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NEXT_DATA_RE = re.compile(
|
||||||
|
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_game_page(html: str) -> dict[str, Any] | None:
|
||||||
|
"""Extract game data dict from a HLTB game page's __NEXT_DATA__."""
|
||||||
|
match = _NEXT_DATA_RE.search(html)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(match.group(1))
|
||||||
|
result: dict[str, Any] = data["props"]["pageProps"]["game"]["data"]
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _as_positive_int(value: object) -> int:
|
||||||
|
"""Convert HLTB numeric JSON values to a positive int, or 0 when invalid."""
|
||||||
|
if isinstance(value, int):
|
||||||
|
return max(0, value)
|
||||||
|
if isinstance(value, float):
|
||||||
|
int_value = int(value)
|
||||||
|
return max(0, int_value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
int_value = int(value)
|
||||||
|
return max(0, int_value)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float:
|
||||||
|
"""Extract base-game leisure hours from game detail data."""
|
||||||
|
games = game_data.get("game", [])
|
||||||
|
if not isinstance(games, list) or not games:
|
||||||
|
return -1
|
||||||
|
if not isinstance(games[0], dict):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
base = games[0]
|
||||||
|
leisure_s = _as_positive_int(base.get("comp_100_h", 0))
|
||||||
|
if leisure_s <= 0:
|
||||||
|
leisure_s = _as_positive_int(base.get("comp_100", 0))
|
||||||
|
if leisure_s <= 0:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
return round(leisure_s / 3600, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_dlc_relationships(game_data: dict[str, Any]) -> list[tuple[int, float]]:
|
||||||
|
"""Extract DLC relationship IDs and fallback hours from detail data."""
|
||||||
|
relationships = game_data.get("relationships", [])
|
||||||
|
if not isinstance(relationships, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
dlcs: list[tuple[int, float]] = []
|
||||||
|
for rel in relationships:
|
||||||
|
if not isinstance(rel, dict):
|
||||||
|
continue
|
||||||
|
if str(rel.get("game_type", "")).lower() != "dlc":
|
||||||
|
continue
|
||||||
|
dlc_id = _as_positive_int(rel.get("game_id", 0))
|
||||||
|
fallback_comp_100 = _as_positive_int(rel.get("comp_100", 0))
|
||||||
|
if fallback_comp_100 > 0:
|
||||||
|
fallback_hours = round(fallback_comp_100 / 3600, 2)
|
||||||
|
else:
|
||||||
|
fallback_hours = 0.0
|
||||||
|
dlcs.append((dlc_id, fallback_hours))
|
||||||
|
|
||||||
|
return dlcs
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_leisure_hours(game_data: dict[str, Any]) -> float:
|
||||||
|
"""Compute total leisure hours: base game + all DLCs.
|
||||||
|
|
||||||
|
Uses ``comp_100_h`` (leisure completionist) from the game detail page.
|
||||||
|
Falls back to ``comp_100`` (average completionist) if leisure unavailable.
|
||||||
|
Also sums leisure time from any DLC listed in ``relationships``.
|
||||||
|
"""
|
||||||
|
base_hours = _extract_base_leisure_hours(game_data)
|
||||||
|
if base_hours <= 0:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
total_hours = base_hours
|
||||||
|
|
||||||
|
# Add DLC leisure times from relationships.
|
||||||
|
for _dlc_id, fallback_hours in _extract_dlc_relationships(game_data):
|
||||||
|
total_hours += fallback_hours
|
||||||
|
|
||||||
|
return round(total_hours, 2)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_detail_one(
|
||||||
|
sem: asyncio.Semaphore,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
hltb_game_id: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Fetch a single HLTB game detail page and parse its data."""
|
||||||
|
async with sem:
|
||||||
|
url = f"{HLTB_BASE_URL}/game/{hltb_game_id}"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
|
||||||
|
),
|
||||||
|
"accept": "text/html",
|
||||||
|
"referer": "https://howlongtobeat.com/",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=headers) as resp:
|
||||||
|
if resp.status == HTTPStatus.OK:
|
||||||
|
html = await resp.text()
|
||||||
|
return _parse_game_page(html)
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||||
|
logger.debug(
|
||||||
|
"HLTB detail fetch failed for game_id=%d: %s",
|
||||||
|
hltb_game_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_leisure_times(
|
||||||
|
search_results: list[HLTBResult],
|
||||||
|
cache: dict[int, float],
|
||||||
|
progress_cb: ProgressCb | None,
|
||||||
|
) -> None:
|
||||||
|
"""Fetch leisure times from game detail pages for all search results.
|
||||||
|
|
||||||
|
Updates ``cache`` in-place with leisure hours (including DLC time).
|
||||||
|
"""
|
||||||
|
valid = [r for r in search_results if r.hltb_game_id > 0]
|
||||||
|
if not valid:
|
||||||
|
return
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=30, sock_read=20)
|
||||||
|
sem = asyncio.Semaphore(MAX_CONCURRENT)
|
||||||
|
connector = aiohttp.TCPConnector(
|
||||||
|
limit=MAX_CONCURRENT,
|
||||||
|
keepalive_timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(valid)
|
||||||
|
done = 0
|
||||||
|
found = 0
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
timeout=timeout,
|
||||||
|
connector=connector,
|
||||||
|
) as session:
|
||||||
|
coros = [_fetch_detail_one(sem, session, r.hltb_game_id) for r in valid]
|
||||||
|
details = await asyncio.gather(*coros)
|
||||||
|
|
||||||
|
dlc_relationships_by_app, dlc_ids = _collect_dlc_relationships(valid, details)
|
||||||
|
dlc_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids)
|
||||||
|
|
||||||
|
for r, game_data in zip(valid, details, strict=False):
|
||||||
|
done += 1
|
||||||
|
if game_data is not None:
|
||||||
|
leisure = _extract_leisure_hours(game_data)
|
||||||
|
if leisure > 0:
|
||||||
|
leisure = _apply_dlc_leisure_overrides(
|
||||||
|
leisure,
|
||||||
|
dlc_relationships_by_app.get(r.app_id, []),
|
||||||
|
dlc_hours_by_id,
|
||||||
|
)
|
||||||
|
r.completionist_hours = leisure
|
||||||
|
cache[r.app_id] = leisure
|
||||||
|
found += 1
|
||||||
|
|
||||||
|
if progress_cb is not None:
|
||||||
|
progress_cb(done, total, found, r.game_name)
|
||||||
|
|
||||||
|
if not done % _SAVE_INTERVAL:
|
||||||
|
save_hltb_cache(cache)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_dlc_relationships(
|
||||||
|
valid: list[HLTBResult],
|
||||||
|
details: list[dict[str, Any] | None],
|
||||||
|
) -> tuple[dict[int, list[tuple[int, float]]], list[int]]:
|
||||||
|
"""Collect DLC relationship IDs for all base-game detail responses."""
|
||||||
|
by_app: dict[int, list[tuple[int, float]]] = {}
|
||||||
|
unique_dlc_ids: set[int] = set()
|
||||||
|
|
||||||
|
for result, game_data in zip(valid, details, strict=False):
|
||||||
|
if game_data is None:
|
||||||
|
continue
|
||||||
|
dlc_rels = _extract_dlc_relationships(game_data)
|
||||||
|
by_app[result.app_id] = dlc_rels
|
||||||
|
for dlc_id, _fallback_hours in dlc_rels:
|
||||||
|
if dlc_id > 0:
|
||||||
|
unique_dlc_ids.add(dlc_id)
|
||||||
|
|
||||||
|
return by_app, sorted(unique_dlc_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_dlc_leisure_hours(
|
||||||
|
sem: asyncio.Semaphore,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
dlc_ids: list[int],
|
||||||
|
) -> dict[int, float]:
|
||||||
|
"""Fetch leisure hours for each DLC game id."""
|
||||||
|
if not dlc_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
coros = [_fetch_detail_one(sem, session, dlc_id) for dlc_id in dlc_ids]
|
||||||
|
dlc_details = await asyncio.gather(*coros)
|
||||||
|
|
||||||
|
dlc_hours_by_id: dict[int, float] = {}
|
||||||
|
for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False):
|
||||||
|
if dlc_data is None:
|
||||||
|
continue
|
||||||
|
dlc_leisure = _extract_base_leisure_hours(dlc_data)
|
||||||
|
if dlc_leisure > 0:
|
||||||
|
dlc_hours_by_id[dlc_id] = dlc_leisure
|
||||||
|
return dlc_hours_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_dlc_leisure_overrides(
|
||||||
|
base_hours: float,
|
||||||
|
dlc_rels: list[tuple[int, float]],
|
||||||
|
dlc_hours_by_id: dict[int, float],
|
||||||
|
) -> float:
|
||||||
|
"""Replace fallback DLC hours with detailed leisure hours when available."""
|
||||||
|
adjusted = base_hours
|
||||||
|
for dlc_id, fallback_hours in dlc_rels:
|
||||||
|
dlc_leisure = dlc_hours_by_id.get(dlc_id, -1.0)
|
||||||
|
if dlc_leisure > 0:
|
||||||
|
adjusted += dlc_leisure - fallback_hours
|
||||||
|
return round(adjusted, 2)
|
||||||
78
python_pkg/steam_backlog_enforcer/_hltb_types.py
Normal file
78
python_pkg/steam_backlog_enforcer/_hltb_types.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""Shared types, constants, and cache I/O for the HLTB integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HLTB_CACHE_FILE = CONFIG_DIR / "hltb_cache.json"
|
||||||
|
MAX_CONCURRENT = 60 # parallel requests to HLTB
|
||||||
|
_SAVE_INTERVAL = 50 # flush cache to disk every N results
|
||||||
|
MIN_SIMILARITY = 0.5
|
||||||
|
HLTB_BASE_URL = "https://howlongtobeat.com"
|
||||||
|
|
||||||
|
# Suffixes that indicate a subset release (prologue, demo, etc.).
|
||||||
|
# Used to avoid preferring "Game - Prologue" over "Game" when both exist.
|
||||||
|
_SUBSET_SUFFIXES = frozenset(
|
||||||
|
{
|
||||||
|
"prologue",
|
||||||
|
"demo",
|
||||||
|
"trial",
|
||||||
|
"lite",
|
||||||
|
"prelude",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type for progress callbacks: (done, total, found, game_name)
|
||||||
|
ProgressCb = Callable[[int, int, int, str], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HLTBResult:
|
||||||
|
"""Result from a HowLongToBeat lookup."""
|
||||||
|
|
||||||
|
app_id: int
|
||||||
|
game_name: str
|
||||||
|
completionist_hours: float
|
||||||
|
similarity: float
|
||||||
|
hltb_game_id: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _AuthInfo:
|
||||||
|
"""HLTB API authentication details."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
hp_key: str = ""
|
||||||
|
hp_val: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def load_hltb_cache() -> dict[int, float]:
|
||||||
|
"""Load the persistent HLTB cache from disk.
|
||||||
|
|
||||||
|
Returns: dict mapping app_id -> completionist_hours (-1 = no data on HLTB).
|
||||||
|
"""
|
||||||
|
if HLTB_CACHE_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(HLTB_CACHE_FILE.read_text(encoding="utf-8"))
|
||||||
|
return {int(k): float(v) for k, v in data.items()}
|
||||||
|
except (json.JSONDecodeError, ValueError, OSError):
|
||||||
|
logger.warning("Corrupt HLTB cache, starting fresh.")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_hltb_cache(cache: dict[int, float]) -> None:
|
||||||
|
"""Save the HLTB cache to disk."""
|
||||||
|
try:
|
||||||
|
_atomic_write(
|
||||||
|
HLTB_CACHE_FILE,
|
||||||
|
json.dumps({str(k): v for k, v in cache.items()}, indent=2) + "\n",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Failed to save HLTB cache")
|
||||||
@ -13,95 +13,34 @@ Fetches leisure completionist hour estimates from howlongtobeat.com with:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from howlongtobeatpy.HTMLRequests import HTMLRequests
|
from howlongtobeatpy.HTMLRequests import HTMLRequests
|
||||||
|
|
||||||
from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
|
from python_pkg.steam_backlog_enforcer._hltb_detail import (
|
||||||
|
_fetch_leisure_times,
|
||||||
logger = logging.getLogger(__name__)
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer._hltb_types import (
|
||||||
HLTB_CACHE_FILE = CONFIG_DIR / "hltb_cache.json"
|
_SAVE_INTERVAL,
|
||||||
MAX_CONCURRENT = 60 # parallel requests to HLTB
|
_SUBSET_SUFFIXES,
|
||||||
_SAVE_INTERVAL = 50 # flush cache to disk every N results
|
HLTB_BASE_URL,
|
||||||
MIN_SIMILARITY = 0.5
|
MAX_CONCURRENT,
|
||||||
|
MIN_SIMILARITY,
|
||||||
# Suffixes that indicate a subset release (prologue, demo, etc.).
|
HLTBResult,
|
||||||
# Used to avoid preferring "Game - Prologue" over "Game" when both exist.
|
ProgressCb,
|
||||||
_SUBSET_SUFFIXES = frozenset(
|
_AuthInfo,
|
||||||
{
|
load_hltb_cache,
|
||||||
"prologue",
|
save_hltb_cache,
|
||||||
"demo",
|
|
||||||
"trial",
|
|
||||||
"lite",
|
|
||||||
"prelude",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Type for progress callbacks: (done, total, found, game_name)
|
logger = logging.getLogger(__name__)
|
||||||
ProgressCb = Callable[[int, int, int, str], None]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HLTBResult:
|
|
||||||
"""Result from a HowLongToBeat lookup."""
|
|
||||||
|
|
||||||
app_id: int
|
|
||||||
game_name: str
|
|
||||||
completionist_hours: float
|
|
||||||
similarity: float
|
|
||||||
hltb_game_id: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _AuthInfo:
|
|
||||||
"""HLTB API authentication details."""
|
|
||||||
|
|
||||||
token: str
|
|
||||||
hp_key: str = ""
|
|
||||||
hp_val: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
HLTB_BASE_URL = "https://howlongtobeat.com"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Cache I/O
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def load_hltb_cache() -> dict[int, float]:
|
|
||||||
"""Load the persistent HLTB cache from disk.
|
|
||||||
|
|
||||||
Returns: dict mapping app_id -> completionist_hours (-1 = no data on HLTB).
|
|
||||||
"""
|
|
||||||
if HLTB_CACHE_FILE.exists():
|
|
||||||
try:
|
|
||||||
data = json.loads(HLTB_CACHE_FILE.read_text(encoding="utf-8"))
|
|
||||||
return {int(k): float(v) for k, v in data.items()}
|
|
||||||
except (json.JSONDecodeError, ValueError, OSError):
|
|
||||||
logger.warning("Corrupt HLTB cache, starting fresh.")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_hltb_cache(cache: dict[int, float]) -> None:
|
|
||||||
"""Save the HLTB cache to disk."""
|
|
||||||
try:
|
|
||||||
_atomic_write(
|
|
||||||
HLTB_CACHE_FILE,
|
|
||||||
json.dumps({str(k): v for k, v in cache.items()}, indent=2) + "\n",
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
logger.exception("Failed to save HLTB cache")
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@ -351,7 +290,7 @@ async def _search_one(
|
|||||||
done = ctx.counter["done"]
|
done = ctx.counter["done"]
|
||||||
|
|
||||||
# Incremental save every _SAVE_INTERVAL lookups.
|
# Incremental save every _SAVE_INTERVAL lookups.
|
||||||
if done % _SAVE_INTERVAL == 0:
|
if not done % _SAVE_INTERVAL:
|
||||||
save_hltb_cache(ctx.cache)
|
save_hltb_cache(ctx.cache)
|
||||||
|
|
||||||
# Report progress.
|
# Report progress.
|
||||||
@ -361,246 +300,6 @@ async def _search_one(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Leisure time + DLC fetching from game detail pages
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_NEXT_DATA_RE = re.compile(
|
|
||||||
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_game_page(html: str) -> dict[str, Any] | None:
|
|
||||||
"""Extract game data dict from a HLTB game page's __NEXT_DATA__."""
|
|
||||||
match = _NEXT_DATA_RE.search(html)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = json.loads(match.group(1))
|
|
||||||
result: dict[str, Any] = data["props"]["pageProps"]["game"]["data"]
|
|
||||||
except (json.JSONDecodeError, KeyError, TypeError):
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _as_positive_int(value: object) -> int:
|
|
||||||
"""Convert HLTB numeric JSON values to a positive int, or 0 when invalid."""
|
|
||||||
if isinstance(value, int):
|
|
||||||
return max(0, value)
|
|
||||||
if isinstance(value, float):
|
|
||||||
int_value = int(value)
|
|
||||||
return max(0, int_value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
try:
|
|
||||||
int_value = int(value)
|
|
||||||
return max(0, int_value)
|
|
||||||
except ValueError:
|
|
||||||
return 0
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float:
|
|
||||||
"""Extract base-game leisure hours from game detail data."""
|
|
||||||
games = game_data.get("game", [])
|
|
||||||
if not isinstance(games, list) or not games:
|
|
||||||
return -1
|
|
||||||
if not isinstance(games[0], dict):
|
|
||||||
return -1
|
|
||||||
|
|
||||||
base = games[0]
|
|
||||||
leisure_s = _as_positive_int(base.get("comp_100_h", 0))
|
|
||||||
if leisure_s <= 0:
|
|
||||||
leisure_s = _as_positive_int(base.get("comp_100", 0))
|
|
||||||
if leisure_s <= 0:
|
|
||||||
return -1
|
|
||||||
|
|
||||||
return round(leisure_s / 3600, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_dlc_relationships(game_data: dict[str, Any]) -> list[tuple[int, float]]:
|
|
||||||
"""Extract DLC relationship IDs and fallback hours from detail data."""
|
|
||||||
relationships = game_data.get("relationships", [])
|
|
||||||
if not isinstance(relationships, list):
|
|
||||||
return []
|
|
||||||
|
|
||||||
dlcs: list[tuple[int, float]] = []
|
|
||||||
for rel in relationships:
|
|
||||||
if not isinstance(rel, dict):
|
|
||||||
continue
|
|
||||||
if str(rel.get("game_type", "")).lower() != "dlc":
|
|
||||||
continue
|
|
||||||
dlc_id = _as_positive_int(rel.get("game_id", 0))
|
|
||||||
fallback_comp_100 = _as_positive_int(rel.get("comp_100", 0))
|
|
||||||
if fallback_comp_100 > 0:
|
|
||||||
fallback_hours = round(fallback_comp_100 / 3600, 2)
|
|
||||||
else:
|
|
||||||
fallback_hours = 0.0
|
|
||||||
dlcs.append((dlc_id, fallback_hours))
|
|
||||||
|
|
||||||
return dlcs
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_leisure_hours(game_data: dict[str, Any]) -> float:
|
|
||||||
"""Compute total leisure hours: base game + all DLCs.
|
|
||||||
|
|
||||||
Uses ``comp_100_h`` (leisure completionist) from the game detail page.
|
|
||||||
Falls back to ``comp_100`` (average completionist) if leisure unavailable.
|
|
||||||
Also sums leisure time from any DLC listed in ``relationships``.
|
|
||||||
"""
|
|
||||||
base_hours = _extract_base_leisure_hours(game_data)
|
|
||||||
if base_hours <= 0:
|
|
||||||
return -1
|
|
||||||
|
|
||||||
total_hours = base_hours
|
|
||||||
|
|
||||||
# Add DLC leisure times from relationships.
|
|
||||||
for _dlc_id, fallback_hours in _extract_dlc_relationships(game_data):
|
|
||||||
total_hours += fallback_hours
|
|
||||||
|
|
||||||
return round(total_hours, 2)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_detail_one(
|
|
||||||
sem: asyncio.Semaphore,
|
|
||||||
session: aiohttp.ClientSession,
|
|
||||||
hltb_game_id: int,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Fetch a single HLTB game detail page and parse its data."""
|
|
||||||
async with sem:
|
|
||||||
url = f"{HLTB_BASE_URL}/game/{hltb_game_id}"
|
|
||||||
headers = {
|
|
||||||
"User-Agent": (
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
|
|
||||||
),
|
|
||||||
"accept": "text/html",
|
|
||||||
"referer": "https://howlongtobeat.com/",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
async with session.get(url, headers=headers) as resp:
|
|
||||||
if resp.status == HTTPStatus.OK:
|
|
||||||
html = await resp.text()
|
|
||||||
return _parse_game_page(html)
|
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
||||||
logger.debug(
|
|
||||||
"HLTB detail fetch failed for game_id=%d: %s",
|
|
||||||
hltb_game_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_leisure_times(
|
|
||||||
search_results: list[HLTBResult],
|
|
||||||
cache: dict[int, float],
|
|
||||||
progress_cb: ProgressCb | None,
|
|
||||||
) -> None:
|
|
||||||
"""Fetch leisure times from game detail pages for all search results.
|
|
||||||
|
|
||||||
Updates ``cache`` in-place with leisure hours (including DLC time).
|
|
||||||
"""
|
|
||||||
valid = [r for r in search_results if r.hltb_game_id > 0]
|
|
||||||
if not valid:
|
|
||||||
return
|
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=30, sock_read=20)
|
|
||||||
sem = asyncio.Semaphore(MAX_CONCURRENT)
|
|
||||||
connector = aiohttp.TCPConnector(
|
|
||||||
limit=MAX_CONCURRENT,
|
|
||||||
keepalive_timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
total = len(valid)
|
|
||||||
done = 0
|
|
||||||
found = 0
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession(
|
|
||||||
timeout=timeout,
|
|
||||||
connector=connector,
|
|
||||||
) as session:
|
|
||||||
coros = [_fetch_detail_one(sem, session, r.hltb_game_id) for r in valid]
|
|
||||||
details = await asyncio.gather(*coros)
|
|
||||||
|
|
||||||
dlc_relationships_by_app, dlc_ids = _collect_dlc_relationships(valid, details)
|
|
||||||
dlc_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids)
|
|
||||||
|
|
||||||
for r, game_data in zip(valid, details, strict=False):
|
|
||||||
done += 1
|
|
||||||
if game_data is not None:
|
|
||||||
leisure = _extract_leisure_hours(game_data)
|
|
||||||
if leisure > 0:
|
|
||||||
leisure = _apply_dlc_leisure_overrides(
|
|
||||||
leisure,
|
|
||||||
dlc_relationships_by_app.get(r.app_id, []),
|
|
||||||
dlc_hours_by_id,
|
|
||||||
)
|
|
||||||
r.completionist_hours = leisure
|
|
||||||
cache[r.app_id] = leisure
|
|
||||||
found += 1
|
|
||||||
|
|
||||||
if progress_cb is not None:
|
|
||||||
progress_cb(done, total, found, r.game_name)
|
|
||||||
|
|
||||||
if done % _SAVE_INTERVAL == 0:
|
|
||||||
save_hltb_cache(cache)
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_dlc_relationships(
|
|
||||||
valid: list[HLTBResult],
|
|
||||||
details: list[dict[str, Any] | None],
|
|
||||||
) -> tuple[dict[int, list[tuple[int, float]]], list[int]]:
|
|
||||||
"""Collect DLC relationship IDs for all base-game detail responses."""
|
|
||||||
by_app: dict[int, list[tuple[int, float]]] = {}
|
|
||||||
unique_dlc_ids: set[int] = set()
|
|
||||||
|
|
||||||
for result, game_data in zip(valid, details, strict=False):
|
|
||||||
if game_data is None:
|
|
||||||
continue
|
|
||||||
dlc_rels = _extract_dlc_relationships(game_data)
|
|
||||||
by_app[result.app_id] = dlc_rels
|
|
||||||
for dlc_id, _fallback_hours in dlc_rels:
|
|
||||||
if dlc_id > 0:
|
|
||||||
unique_dlc_ids.add(dlc_id)
|
|
||||||
|
|
||||||
return by_app, sorted(unique_dlc_ids)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_dlc_leisure_hours(
|
|
||||||
sem: asyncio.Semaphore,
|
|
||||||
session: aiohttp.ClientSession,
|
|
||||||
dlc_ids: list[int],
|
|
||||||
) -> dict[int, float]:
|
|
||||||
"""Fetch leisure hours for each DLC game id."""
|
|
||||||
if not dlc_ids:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
coros = [_fetch_detail_one(sem, session, dlc_id) for dlc_id in dlc_ids]
|
|
||||||
dlc_details = await asyncio.gather(*coros)
|
|
||||||
|
|
||||||
dlc_hours_by_id: dict[int, float] = {}
|
|
||||||
for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False):
|
|
||||||
if dlc_data is None:
|
|
||||||
continue
|
|
||||||
dlc_leisure = _extract_base_leisure_hours(dlc_data)
|
|
||||||
if dlc_leisure > 0:
|
|
||||||
dlc_hours_by_id[dlc_id] = dlc_leisure
|
|
||||||
return dlc_hours_by_id
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_dlc_leisure_overrides(
|
|
||||||
base_hours: float,
|
|
||||||
dlc_rels: list[tuple[int, float]],
|
|
||||||
dlc_hours_by_id: dict[int, float],
|
|
||||||
) -> float:
|
|
||||||
"""Replace fallback DLC hours with detailed leisure hours when available."""
|
|
||||||
adjusted = base_hours
|
|
||||||
for dlc_id, fallback_hours in dlc_rels:
|
|
||||||
dlc_leisure = dlc_hours_by_id.get(dlc_id, -1.0)
|
|
||||||
if dlc_leisure > 0:
|
|
||||||
adjusted += dlc_leisure - fallback_hours
|
|
||||||
return round(adjusted, 2)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_batch(
|
async def _fetch_batch(
|
||||||
games: list[tuple[int, str]],
|
games: list[tuple[int, str]],
|
||||||
cache: dict[int, float],
|
cache: dict[int, float],
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from python_pkg.steam_backlog_enforcer._cmd_done import cmd_done
|
||||||
from python_pkg.steam_backlog_enforcer._enforce_loop import (
|
from python_pkg.steam_backlog_enforcer._enforce_loop import (
|
||||||
do_enforce,
|
do_enforce,
|
||||||
get_all_owned_app_ids,
|
get_all_owned_app_ids,
|
||||||
@ -15,10 +16,6 @@ from python_pkg.steam_backlog_enforcer.config import (
|
|||||||
interactive_setup,
|
interactive_setup,
|
||||||
load_snapshot,
|
load_snapshot,
|
||||||
)
|
)
|
||||||
from python_pkg.steam_backlog_enforcer.enforcer import (
|
|
||||||
enforce_allowed_game,
|
|
||||||
send_notification,
|
|
||||||
)
|
|
||||||
from python_pkg.steam_backlog_enforcer.game_install import (
|
from python_pkg.steam_backlog_enforcer.game_install import (
|
||||||
PROTECTED_APP_IDS,
|
PROTECTED_APP_IDS,
|
||||||
_echo,
|
_echo,
|
||||||
@ -27,22 +24,16 @@ from python_pkg.steam_backlog_enforcer.game_install import (
|
|||||||
is_game_installed,
|
is_game_installed,
|
||||||
uninstall_other_games,
|
uninstall_other_games,
|
||||||
)
|
)
|
||||||
from python_pkg.steam_backlog_enforcer.hltb import (
|
|
||||||
fetch_hltb_times_cached,
|
|
||||||
load_hltb_cache,
|
|
||||||
)
|
|
||||||
from python_pkg.steam_backlog_enforcer.library_hider import (
|
from python_pkg.steam_backlog_enforcer.library_hider import (
|
||||||
hide_other_games,
|
hide_other_games,
|
||||||
restart_steam,
|
restart_steam,
|
||||||
unhide_all_games,
|
unhide_all_games,
|
||||||
)
|
)
|
||||||
from python_pkg.steam_backlog_enforcer.scanning import (
|
from python_pkg.steam_backlog_enforcer.scanning import (
|
||||||
_pick_playable_candidate,
|
|
||||||
do_check,
|
do_check,
|
||||||
do_scan,
|
do_scan,
|
||||||
pick_next_game,
|
|
||||||
)
|
)
|
||||||
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
|
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
|
||||||
from python_pkg.steam_backlog_enforcer.store_blocker import (
|
from python_pkg.steam_backlog_enforcer.store_blocker import (
|
||||||
block_store,
|
block_store,
|
||||||
is_store_blocked,
|
is_store_blocked,
|
||||||
@ -58,7 +49,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_LIST_DISPLAY_LIMIT = 50
|
_LIST_DISPLAY_LIMIT = 50
|
||||||
_MIN_CLI_ARGS = 2
|
_MIN_CLI_ARGS = 2
|
||||||
_REASSIGN_REFRESH_LIMIT = 50
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@ -284,213 +274,6 @@ def cmd_unhide(config: Config, _state: State) -> None:
|
|||||||
_echo("Done!")
|
_echo("Done!")
|
||||||
|
|
||||||
|
|
||||||
def _apply_cached_hours_to_games(
|
|
||||||
games: list[GameInfo],
|
|
||||||
hltb_cache: dict[int, float],
|
|
||||||
) -> None:
|
|
||||||
"""Overlay cached HLTB hours onto games (including cached misses)."""
|
|
||||||
for game in games:
|
|
||||||
if game.app_id in hltb_cache:
|
|
||||||
game.completionist_hours = hltb_cache[game.app_id]
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_uncached_shortlist_hours(
|
|
||||||
games: list[GameInfo],
|
|
||||||
hltb_cache: dict[int, float],
|
|
||||||
skip: set[int],
|
|
||||||
*,
|
|
||||||
upper_bound_hours: float | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Refresh likely-short uncached games to avoid stale snapshot decisions."""
|
|
||||||
shorter_uncached = [
|
|
||||||
(g.app_id, g.name)
|
|
||||||
for g in sorted(
|
|
||||||
(
|
|
||||||
game
|
|
||||||
for game in games
|
|
||||||
if not game.is_complete
|
|
||||||
and game.app_id not in skip
|
|
||||||
and game.completionist_hours > 0
|
|
||||||
and game.app_id not in hltb_cache
|
|
||||||
and (
|
|
||||||
upper_bound_hours is None
|
|
||||||
or game.completionist_hours < upper_bound_hours
|
|
||||||
)
|
|
||||||
),
|
|
||||||
key=lambda game: game.completionist_hours,
|
|
||||||
)[:_REASSIGN_REFRESH_LIMIT]
|
|
||||||
]
|
|
||||||
if shorter_uncached:
|
|
||||||
refreshed = fetch_hltb_times_cached(shorter_uncached)
|
|
||||||
hltb_cache.update(refreshed)
|
|
||||||
|
|
||||||
|
|
||||||
def _try_reassign_shorter_game(
|
|
||||||
hltb_cache: dict[int, float],
|
|
||||||
app_id: int,
|
|
||||||
hours: float,
|
|
||||||
state: State,
|
|
||||||
config: Config,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if a shorter game is available and reassign if so."""
|
|
||||||
snapshot_data = load_snapshot()
|
|
||||||
if not snapshot_data:
|
|
||||||
return False
|
|
||||||
all_games = [GameInfo.from_snapshot(d) for d in snapshot_data]
|
|
||||||
skip = set(config.skip_app_ids) | set(state.finished_app_ids)
|
|
||||||
_refresh_uncached_shortlist_hours(
|
|
||||||
all_games,
|
|
||||||
hltb_cache,
|
|
||||||
skip,
|
|
||||||
upper_bound_hours=hours,
|
|
||||||
)
|
|
||||||
_apply_cached_hours_to_games(all_games, hltb_cache)
|
|
||||||
candidates = [
|
|
||||||
g
|
|
||||||
for g in all_games
|
|
||||||
if not g.is_complete and g.app_id not in skip and g.completionist_hours > 0
|
|
||||||
]
|
|
||||||
candidates.sort(key=lambda g: g.completionist_hours)
|
|
||||||
if not candidates or candidates[0].app_id == app_id:
|
|
||||||
return False
|
|
||||||
# Filter out Linux-incompatible games before deciding to reassign.
|
|
||||||
playable = _pick_playable_candidate(
|
|
||||||
[c for c in candidates if c.app_id != app_id],
|
|
||||||
)
|
|
||||||
if playable is None or playable.completionist_hours >= hours:
|
|
||||||
return False
|
|
||||||
_echo(
|
|
||||||
f"\n Reassigning: {playable.name} is shorter"
|
|
||||||
f" (~{playable.completionist_hours:.1f}h vs ~{hours:.1f}h)"
|
|
||||||
)
|
|
||||||
pick_next_game(all_games, state, config)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _finalize_completion(
|
|
||||||
config: Config,
|
|
||||||
state: State,
|
|
||||||
game_name: str,
|
|
||||||
app_id: int,
|
|
||||||
) -> None:
|
|
||||||
"""Mark game complete, pick next, hide non-assigned games, notify."""
|
|
||||||
_echo(f"\n COMPLETED: {game_name}!")
|
|
||||||
state.finished_app_ids.append(app_id)
|
|
||||||
|
|
||||||
snapshot_data = load_snapshot()
|
|
||||||
_echo("\nPicking next game...")
|
|
||||||
if not snapshot_data:
|
|
||||||
_echo(" No snapshot found. Run 'scan' first.")
|
|
||||||
state.current_app_id = None
|
|
||||||
state.current_game_name = ""
|
|
||||||
state.save()
|
|
||||||
return
|
|
||||||
|
|
||||||
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
|
|
||||||
hltb_cache = load_hltb_cache()
|
|
||||||
skip = set(config.skip_app_ids) | set(state.finished_app_ids)
|
|
||||||
_refresh_uncached_shortlist_hours(games, hltb_cache, skip)
|
|
||||||
_apply_cached_hours_to_games(games, hltb_cache)
|
|
||||||
pick_next_game(games, state, config)
|
|
||||||
|
|
||||||
if state.current_app_id is None:
|
|
||||||
_echo(" No more games to assign!")
|
|
||||||
return
|
|
||||||
|
|
||||||
owned_ids = get_all_owned_app_ids(config)
|
|
||||||
if owned_ids:
|
|
||||||
hidden = hide_other_games(owned_ids, state.current_app_id)
|
|
||||||
if hidden > 0:
|
|
||||||
_echo(f"\n Library: hid {hidden} games")
|
|
||||||
|
|
||||||
send_notification(
|
|
||||||
"Game Complete!",
|
|
||||||
f"Finished {game_name}! Now playing: {state.current_game_name}",
|
|
||||||
)
|
|
||||||
_echo(f"\nAll done! Go play {state.current_game_name}!")
|
|
||||||
|
|
||||||
|
|
||||||
def _enforce_on_done(config: Config, state: State) -> None:
|
|
||||||
"""Run a single enforcement pass during the 'done' command.
|
|
||||||
|
|
||||||
Kills unauthorized game processes, uninstalls unauthorized games,
|
|
||||||
and ensures the assigned game is installed.
|
|
||||||
"""
|
|
||||||
if state.current_app_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if config.kill_unauthorized_games:
|
|
||||||
violations = enforce_allowed_game(
|
|
||||||
state.current_app_id,
|
|
||||||
kill_unauthorized=True,
|
|
||||||
)
|
|
||||||
for pid, app_id in violations:
|
|
||||||
_echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})")
|
|
||||||
|
|
||||||
if config.uninstall_other_games:
|
|
||||||
count = uninstall_other_games(state.current_app_id)
|
|
||||||
if count:
|
|
||||||
_echo(f" Uninstalled {count} unauthorized game(s)")
|
|
||||||
|
|
||||||
if not is_game_installed(state.current_app_id):
|
|
||||||
_echo(f" Re-installing {state.current_game_name}...")
|
|
||||||
install_game(
|
|
||||||
state.current_app_id,
|
|
||||||
state.current_game_name,
|
|
||||||
config.steam_id,
|
|
||||||
use_steam_protocol=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_done(config: Config, state: State) -> None:
|
|
||||||
"""Check completion, pick next game, uninstall & hide.
|
|
||||||
|
|
||||||
All-in-one command for after finishing a game:
|
|
||||||
1. Verify 100% achievements on Steam.
|
|
||||||
2. Pick the next game (shortest HLTB leisure+dlc time).
|
|
||||||
3. Uninstall all non-assigned games.
|
|
||||||
4. Hide all non-assigned games in the Steam library.
|
|
||||||
5. Install the newly assigned game.
|
|
||||||
"""
|
|
||||||
if state.current_app_id is None:
|
|
||||||
_echo("No game currently assigned. Run 'scan' first.")
|
|
||||||
return
|
|
||||||
|
|
||||||
client = SteamAPIClient(config.steam_api_key, config.steam_id)
|
|
||||||
game_name = state.current_game_name
|
|
||||||
app_id = state.current_app_id
|
|
||||||
|
|
||||||
_echo(f"Checking {game_name} (AppID={app_id})...")
|
|
||||||
game = client.refresh_single_game(app_id, game_name)
|
|
||||||
if game is None:
|
|
||||||
_echo(" Could not fetch achievement data from Steam.")
|
|
||||||
return
|
|
||||||
|
|
||||||
_echo(
|
|
||||||
f" Progress: {game.unlocked_achievements}/{game.total_achievements}"
|
|
||||||
f" ({game.completion_pct:.1f}%)"
|
|
||||||
)
|
|
||||||
|
|
||||||
hltb_cache = load_hltb_cache()
|
|
||||||
hours = hltb_cache.get(app_id, -1.0)
|
|
||||||
if hours < 0:
|
|
||||||
hltb_cache = fetch_hltb_times_cached([(app_id, game_name)])
|
|
||||||
hours = hltb_cache.get(app_id, -1.0)
|
|
||||||
if hours > 0:
|
|
||||||
_echo(f" HLTB leisure+dlc estimate: {hours:.1f} hours")
|
|
||||||
|
|
||||||
if _try_reassign_shorter_game(hltb_cache, app_id, hours, state, config):
|
|
||||||
return
|
|
||||||
|
|
||||||
if not game.is_complete:
|
|
||||||
remaining = game.total_achievements - game.unlocked_achievements
|
|
||||||
_echo(f"\n NOT COMPLETE: {remaining} achievements remaining. Keep going!")
|
|
||||||
_enforce_on_done(config, state)
|
|
||||||
return
|
|
||||||
|
|
||||||
_finalize_completion(config, state, game_name, app_id)
|
|
||||||
|
|
||||||
|
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
"scan": ("Scan library & assign a game", do_scan),
|
"scan": ("Scan library & assign a game", do_scan),
|
||||||
"check": ("Check assigned game completion", do_check),
|
"check": ("Check assigned game completion", do_check),
|
||||||
|
|||||||
171
python_pkg/steam_backlog_enforcer/tests/test_cmd_done.py
Normal file
171
python_pkg/steam_backlog_enforcer/tests/test_cmd_done.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for _cmd_done module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from python_pkg.steam_backlog_enforcer._cmd_done import _try_reassign_shorter_game
|
||||||
|
from python_pkg.steam_backlog_enforcer.config import Config, State
|
||||||
|
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
|
||||||
|
|
||||||
|
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
|
||||||
|
|
||||||
|
|
||||||
|
def _snap(
|
||||||
|
app_id: int = 1,
|
||||||
|
name: str = "G",
|
||||||
|
total: int = 10,
|
||||||
|
unlocked: int = 0,
|
||||||
|
hours: float = -1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"app_id": app_id,
|
||||||
|
"name": name,
|
||||||
|
"total_achievements": total,
|
||||||
|
"unlocked_achievements": unlocked,
|
||||||
|
"playtime_minutes": 60,
|
||||||
|
"completionist_hours": hours,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTryReassignShorterGame:
|
||||||
|
"""Tests for _try_reassign_shorter_game."""
|
||||||
|
|
||||||
|
def test_no_snapshot(self) -> None:
|
||||||
|
with patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=None):
|
||||||
|
assert not _try_reassign_shorter_game({}, 1, 10.0, State(), Config())
|
||||||
|
|
||||||
|
def test_no_shorter_candidate(self) -> None:
|
||||||
|
snap = [_snap(1, "G", 10, 5, 10.0), _snap(2, "H", 10, 5, -1)]
|
||||||
|
with (
|
||||||
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
|
):
|
||||||
|
result = _try_reassign_shorter_game(
|
||||||
|
{1: 10.0},
|
||||||
|
1,
|
||||||
|
10.0,
|
||||||
|
State(),
|
||||||
|
Config(),
|
||||||
|
)
|
||||||
|
assert not result
|
||||||
|
|
||||||
|
def test_reassigns(self) -> None:
|
||||||
|
snap = [
|
||||||
|
_snap(1, "Long", 10, 5, 100.0),
|
||||||
|
_snap(2, "Short", 10, 5, 5.0),
|
||||||
|
]
|
||||||
|
state = State(current_app_id=1, current_game_name="Long")
|
||||||
|
short_game = GameInfo(
|
||||||
|
app_id=2,
|
||||||
|
name="Short",
|
||||||
|
total_achievements=10,
|
||||||
|
unlocked_achievements=5,
|
||||||
|
playtime_minutes=60,
|
||||||
|
completionist_hours=5.0,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
|
patch(
|
||||||
|
f"{CMD_DONE_PKG}._pick_playable_candidate",
|
||||||
|
return_value=short_game,
|
||||||
|
),
|
||||||
|
patch(f"{CMD_DONE_PKG}.pick_next_game"),
|
||||||
|
):
|
||||||
|
result = _try_reassign_shorter_game(
|
||||||
|
{1: 100.0, 2: 5.0},
|
||||||
|
1,
|
||||||
|
100.0,
|
||||||
|
state,
|
||||||
|
Config(),
|
||||||
|
)
|
||||||
|
assert result
|
||||||
|
|
||||||
|
def test_playable_none(self) -> None:
|
||||||
|
snap = [
|
||||||
|
_snap(1, "Long", 10, 5, 100.0),
|
||||||
|
_snap(2, "Short", 10, 5, 5.0),
|
||||||
|
]
|
||||||
|
with (
|
||||||
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
|
patch(f"{CMD_DONE_PKG}._pick_playable_candidate", return_value=None),
|
||||||
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
|
):
|
||||||
|
result = _try_reassign_shorter_game(
|
||||||
|
{1: 100.0, 2: 5.0},
|
||||||
|
1,
|
||||||
|
100.0,
|
||||||
|
State(),
|
||||||
|
Config(),
|
||||||
|
)
|
||||||
|
assert not result
|
||||||
|
|
||||||
|
def test_playable_longer(self) -> None:
|
||||||
|
"""Playable candidate is longer than current — no reassign."""
|
||||||
|
snap = [
|
||||||
|
_snap(1, "Short", 10, 5, 10.0),
|
||||||
|
_snap(2, "Long", 10, 5, 200.0),
|
||||||
|
]
|
||||||
|
long_game = GameInfo(
|
||||||
|
app_id=2,
|
||||||
|
name="Long",
|
||||||
|
total_achievements=10,
|
||||||
|
unlocked_achievements=5,
|
||||||
|
playtime_minutes=60,
|
||||||
|
completionist_hours=200.0,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
|
patch(f"{CMD_DONE_PKG}._pick_playable_candidate", return_value=long_game),
|
||||||
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
|
):
|
||||||
|
result = _try_reassign_shorter_game(
|
||||||
|
{1: 10.0, 2: 200.0},
|
||||||
|
1,
|
||||||
|
10.0,
|
||||||
|
State(),
|
||||||
|
Config(),
|
||||||
|
)
|
||||||
|
assert not result
|
||||||
|
|
||||||
|
def test_refreshes_stale_shorter_snapshot_entry(self) -> None:
|
||||||
|
"""Uncached shorter snapshot candidates are refreshed before reassigning."""
|
||||||
|
snap = [
|
||||||
|
_snap(1, "Current", 10, 5, 20.1),
|
||||||
|
_snap(2, "Lacuna", 10, 0, 0.9),
|
||||||
|
]
|
||||||
|
state = State(current_app_id=1, current_game_name="Current")
|
||||||
|
refreshed_short = GameInfo(
|
||||||
|
app_id=2,
|
||||||
|
name="Lacuna",
|
||||||
|
total_achievements=10,
|
||||||
|
unlocked_achievements=0,
|
||||||
|
playtime_minutes=60,
|
||||||
|
completionist_hours=18.8,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
|
patch(
|
||||||
|
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||||
|
return_value={2: 18.8},
|
||||||
|
) as mock_fetch_hltb,
|
||||||
|
patch(
|
||||||
|
f"{CMD_DONE_PKG}._pick_playable_candidate",
|
||||||
|
return_value=refreshed_short,
|
||||||
|
) as mock_pick_playable,
|
||||||
|
patch(f"{CMD_DONE_PKG}.pick_next_game"),
|
||||||
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
|
):
|
||||||
|
result = _try_reassign_shorter_game(
|
||||||
|
{1: 20.1},
|
||||||
|
1,
|
||||||
|
20.1,
|
||||||
|
state,
|
||||||
|
Config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result
|
||||||
|
mock_fetch_hltb.assert_called_once_with([(2, "Lacuna")])
|
||||||
|
mock_pick_playable.assert_called_once()
|
||||||
@ -8,35 +8,19 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from typing_extensions import Self
|
|
||||||
|
|
||||||
from python_pkg.steam_backlog_enforcer.hltb import (
|
from python_pkg.steam_backlog_enforcer.hltb import (
|
||||||
HLTBResult,
|
|
||||||
_apply_dlc_leisure_overrides,
|
|
||||||
_as_positive_int,
|
|
||||||
_AuthInfo,
|
_AuthInfo,
|
||||||
_build_search_payload,
|
_build_search_payload,
|
||||||
_collect_dlc_relationships,
|
|
||||||
_extract_base_leisure_hours,
|
|
||||||
_extract_dlc_relationships,
|
|
||||||
_extract_leisure_hours,
|
|
||||||
_fetch_batch,
|
|
||||||
_fetch_detail_one,
|
|
||||||
_fetch_dlc_leisure_hours,
|
|
||||||
_fetch_leisure_times,
|
|
||||||
_get_auth_info,
|
_get_auth_info,
|
||||||
_get_hltb_search_url,
|
_get_hltb_search_url,
|
||||||
_parse_game_page,
|
|
||||||
_pick_best_hltb_entry,
|
_pick_best_hltb_entry,
|
||||||
_search_one,
|
|
||||||
_SearchCtx,
|
|
||||||
_similarity,
|
_similarity,
|
||||||
load_hltb_cache,
|
load_hltb_cache,
|
||||||
save_hltb_cache,
|
save_hltb_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@ -47,7 +31,7 @@ class TestHltbCache:
|
|||||||
cache_file = tmp_path / "hltb_cache.json"
|
cache_file = tmp_path / "hltb_cache.json"
|
||||||
cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8")
|
cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file
|
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
|
||||||
):
|
):
|
||||||
result = load_hltb_cache()
|
result = load_hltb_cache()
|
||||||
assert result == {440: 10.5}
|
assert result == {440: 10.5}
|
||||||
@ -55,7 +39,7 @@ class TestHltbCache:
|
|||||||
def test_load_cache_missing(self, tmp_path: Path) -> None:
|
def test_load_cache_missing(self, tmp_path: Path) -> None:
|
||||||
cache_file = tmp_path / "nonexistent.json"
|
cache_file = tmp_path / "nonexistent.json"
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file
|
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
|
||||||
):
|
):
|
||||||
assert load_hltb_cache() == {}
|
assert load_hltb_cache() == {}
|
||||||
|
|
||||||
@ -63,22 +47,25 @@ class TestHltbCache:
|
|||||||
cache_file = tmp_path / "hltb_cache.json"
|
cache_file = tmp_path / "hltb_cache.json"
|
||||||
cache_file.write_text("not json", encoding="utf-8")
|
cache_file.write_text("not json", encoding="utf-8")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file
|
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
|
||||||
):
|
):
|
||||||
assert load_hltb_cache() == {}
|
assert load_hltb_cache() == {}
|
||||||
|
|
||||||
def test_save_cache(self, tmp_path: Path) -> None:
|
def test_save_cache(self, tmp_path: Path) -> None:
|
||||||
cache_file = tmp_path / "hltb_cache.json"
|
cache_file = tmp_path / "hltb_cache.json"
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file),
|
patch(
|
||||||
patch("python_pkg.steam_backlog_enforcer.hltb.CONFIG_DIR", tmp_path),
|
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE",
|
||||||
|
cache_file,
|
||||||
|
),
|
||||||
|
patch("python_pkg.steam_backlog_enforcer._hltb_types.CONFIG_DIR", tmp_path),
|
||||||
):
|
):
|
||||||
save_hltb_cache({440: 10.5})
|
save_hltb_cache({440: 10.5})
|
||||||
assert cache_file.exists()
|
assert cache_file.exists()
|
||||||
|
|
||||||
def test_save_cache_os_error(self, tmp_path: Path) -> None:
|
def test_save_cache_os_error(self, tmp_path: Path) -> None:
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._atomic_write",
|
"python_pkg.steam_backlog_enforcer._hltb_types._atomic_write",
|
||||||
side_effect=OSError("disk full"),
|
side_effect=OSError("disk full"),
|
||||||
):
|
):
|
||||||
save_hltb_cache({440: 10.5}) # Should not raise
|
save_hltb_cache({440: 10.5}) # Should not raise
|
||||||
@ -334,774 +321,3 @@ class TestPickBestHltbEntry:
|
|||||||
result = _pick_best_hltb_entry("Killing Floor", [(spinoff, 0.7), (base, 1.0)])
|
result = _pick_best_hltb_entry("Killing Floor", [(spinoff, 0.7), (base, 1.0)])
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["game_name"] == "Killing Floor"
|
assert result[0]["game_name"] == "Killing Floor"
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
|
||||||
"""Async context manager mimicking aiohttp response."""
|
|
||||||
|
|
||||||
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
|
|
||||||
self.status = status
|
|
||||||
self._json_data = json_data or {}
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args: object) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def json(self) -> dict[str, Any]:
|
|
||||||
return self._json_data
|
|
||||||
|
|
||||||
|
|
||||||
def _make_session(resp: _FakeResponse) -> MagicMock:
|
|
||||||
session = MagicMock()
|
|
||||||
session.post.return_value = resp
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(
|
|
||||||
session: MagicMock,
|
|
||||||
*,
|
|
||||||
cache: dict[int, float] | None = None,
|
|
||||||
progress_cb: Callable[..., object] | None = None,
|
|
||||||
) -> _SearchCtx:
|
|
||||||
return _SearchCtx(
|
|
||||||
session=session,
|
|
||||||
search_url="https://example.com/search",
|
|
||||||
headers={},
|
|
||||||
cache=cache if cache is not None else {},
|
|
||||||
counter={"done": 0, "found": 0},
|
|
||||||
total=1,
|
|
||||||
progress_cb=progress_cb,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearchOne:
|
|
||||||
"""Tests for _search_one."""
|
|
||||||
|
|
||||||
def test_found(self) -> None:
|
|
||||||
resp = _FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"game_name": "TF2",
|
|
||||||
"game_alias": "",
|
|
||||||
"comp_100": 180000,
|
|
||||||
"game_id": 12345,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is not None
|
|
||||||
assert result.app_id == 440
|
|
||||||
|
|
||||||
def test_not_found(self) -> None:
|
|
||||||
resp = _FakeResponse(200, {"data": []})
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is None
|
|
||||||
assert ctx.cache[440] == -1
|
|
||||||
|
|
||||||
def test_error(self) -> None:
|
|
||||||
session = MagicMock()
|
|
||||||
session.post.side_effect = aiohttp.ClientError("fail")
|
|
||||||
ctx = _make_ctx(session)
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_non_200(self) -> None:
|
|
||||||
resp = _FakeResponse(500)
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_with_progress_cb(self) -> None:
|
|
||||||
resp = _FakeResponse(200, {"data": []})
|
|
||||||
cb = MagicMock()
|
|
||||||
ctx = _make_ctx(_make_session(resp), progress_cb=cb)
|
|
||||||
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
cb.assert_called_once()
|
|
||||||
|
|
||||||
def test_low_similarity_skipped(self) -> None:
|
|
||||||
resp = _FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"game_name": "Completely Different Name",
|
|
||||||
"game_alias": "",
|
|
||||||
"comp_100": 3600,
|
|
||||||
"game_id": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_zero_comp_100_skipped(self) -> None:
|
|
||||||
resp = _FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"game_name": "TF2",
|
|
||||||
"game_alias": "",
|
|
||||||
"comp_100": 0,
|
|
||||||
"game_id": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_alias_match(self) -> None:
|
|
||||||
resp = _FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"game_name": "Team Fortress 2",
|
|
||||||
"game_alias": "TF2",
|
|
||||||
"comp_100": 180000,
|
|
||||||
"game_id": 12345,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
def test_full_edition_colon(self) -> None:
|
|
||||||
resp = _FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"game_name": "TF2: Complete",
|
|
||||||
"game_alias": "",
|
|
||||||
"comp_100": 180000,
|
|
||||||
"game_id": 99,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
def test_full_edition_dash(self) -> None:
|
|
||||||
resp = _FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"game_name": "TF2 - Complete",
|
|
||||||
"game_alias": "",
|
|
||||||
"comp_100": 180000,
|
|
||||||
"game_id": 99,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
def test_save_interval(self) -> None:
|
|
||||||
"""Trigger the _SAVE_INTERVAL branch."""
|
|
||||||
resp = _FakeResponse(200, {"data": []})
|
|
||||||
ctx = _make_ctx(_make_session(resp))
|
|
||||||
# Set done to one less than _SAVE_INTERVAL so it triggers save
|
|
||||||
from python_pkg.steam_backlog_enforcer.hltb import _SAVE_INTERVAL
|
|
||||||
|
|
||||||
ctx.counter["done"] = _SAVE_INTERVAL - 1
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb.save_hltb_cache"
|
|
||||||
) as mock_save:
|
|
||||||
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
|
||||||
mock_save.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchBatchHltb:
|
|
||||||
"""Tests for _fetch_batch (the hltb version)."""
|
|
||||||
|
|
||||||
def test_no_auth(self) -> None:
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
|
||||||
return_value="https://example.com",
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
|
||||||
assert results == []
|
|
||||||
|
|
||||||
def test_with_auth(self) -> None:
|
|
||||||
auth = _AuthInfo("token123", "ign_x", "ff")
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
|
||||||
return_value="https://example.com",
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=auth,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._search_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=HLTBResult(
|
|
||||||
app_id=440,
|
|
||||||
game_name="TF2",
|
|
||||||
completionist_hours=50.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=12345,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_leisure_times",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
|
||||||
assert len(results) == 1
|
|
||||||
|
|
||||||
def test_with_auth_no_hp(self) -> None:
|
|
||||||
auth = _AuthInfo("tok123")
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
|
||||||
return_value="https://example.com",
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=auth,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._search_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_leisure_times",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
|
||||||
assert results == []
|
|
||||||
|
|
||||||
def test_filters_none_results(self) -> None:
|
|
||||||
auth = _AuthInfo("tok123")
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
|
||||||
return_value="https://example.com",
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=auth,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._search_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_leisure_times",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
|
||||||
assert results == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseGamePage:
|
|
||||||
"""Tests for _parse_game_page."""
|
|
||||||
|
|
||||||
def test_valid_html(self) -> None:
|
|
||||||
game_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
next_data = {
|
|
||||||
"props": {"pageProps": {"game": {"data": game_data}}},
|
|
||||||
}
|
|
||||||
html = (
|
|
||||||
'<html><script id="__NEXT_DATA__" type="application/json">'
|
|
||||||
+ json.dumps(next_data)
|
|
||||||
+ "</script></html>"
|
|
||||||
)
|
|
||||||
assert _parse_game_page(html) == game_data
|
|
||||||
|
|
||||||
def test_no_script_tag(self) -> None:
|
|
||||||
assert _parse_game_page("<html></html>") is None
|
|
||||||
|
|
||||||
def test_bad_json(self) -> None:
|
|
||||||
html = '<script id="__NEXT_DATA__" type="application/json">{not json}</script>'
|
|
||||||
assert _parse_game_page(html) is None
|
|
||||||
|
|
||||||
def test_missing_keys(self) -> None:
|
|
||||||
html = (
|
|
||||||
'<script id="__NEXT_DATA__" type="application/json">{"props": {}}</script>'
|
|
||||||
)
|
|
||||||
assert _parse_game_page(html) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractLeisureHours:
|
|
||||||
"""Tests for _extract_leisure_hours."""
|
|
||||||
|
|
||||||
def test_leisure_time_only(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == round(21243 / 3600, 2)
|
|
||||||
|
|
||||||
def test_leisure_with_dlc(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
|
|
||||||
"relationships": [
|
|
||||||
{"game_type": "dlc", "comp_100": 12298},
|
|
||||||
{"game_type": "dlc", "comp_100": 3600},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2)
|
|
||||||
|
|
||||||
def test_fallback_to_comp_100(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100": 7200}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == round(7200 / 3600, 2)
|
|
||||||
|
|
||||||
def test_no_game_data(self) -> None:
|
|
||||||
assert _extract_leisure_hours({"game": [], "relationships": []}) == -1
|
|
||||||
|
|
||||||
def test_zero_leisure(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 0, "comp_100": 0}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == -1
|
|
||||||
|
|
||||||
def test_no_game_key(self) -> None:
|
|
||||||
assert _extract_leisure_hours({"relationships": []}) == -1
|
|
||||||
|
|
||||||
def test_non_dlc_relationship_ignored(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 3600}],
|
|
||||||
"relationships": [
|
|
||||||
{"game_type": "game", "comp_100": 9999},
|
|
||||||
{"game_type": "dlc", "comp_100": 1800},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2)
|
|
||||||
|
|
||||||
def test_dlc_zero_comp_100_skipped(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 3600}],
|
|
||||||
"relationships": [
|
|
||||||
{"game_type": "dlc", "comp_100": 0},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == round(3600 / 3600, 2)
|
|
||||||
|
|
||||||
def test_negative_leisure(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": -1, "comp_100": -1}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == -1
|
|
||||||
|
|
||||||
def test_string_numeric_fields(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": "7200", "comp_100": "3600"}],
|
|
||||||
"relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2)
|
|
||||||
|
|
||||||
def test_bad_string_falls_back_to_comp_100(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": "bad", "comp_100": "3600"}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == 1.0
|
|
||||||
|
|
||||||
def test_relationships_not_list(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 3600}],
|
|
||||||
"relationships": "not-a-list",
|
|
||||||
}
|
|
||||||
assert _extract_leisure_hours(data) == 1.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestInternalHelpers:
|
|
||||||
"""Tests for internal helper coverage."""
|
|
||||||
|
|
||||||
def test_as_positive_int_float(self) -> None:
|
|
||||||
assert _as_positive_int(1.9) == 1
|
|
||||||
|
|
||||||
def test_as_positive_int_invalid_type(self) -> None:
|
|
||||||
assert _as_positive_int(object()) == 0
|
|
||||||
|
|
||||||
def test_extract_base_leisure_non_dict_game(self) -> None:
|
|
||||||
data: dict[str, Any] = {"game": [123]}
|
|
||||||
assert _extract_base_leisure_hours(data) == -1
|
|
||||||
|
|
||||||
def test_extract_dlc_relationships_skips_non_dict(self) -> None:
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"relationships": [
|
|
||||||
"bad",
|
|
||||||
{"game_type": "dlc", "game_id": 7, "comp_100": 3600},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert _extract_dlc_relationships(data) == [(7, 1.0)]
|
|
||||||
|
|
||||||
def test_collect_dlc_relationships_ignores_non_positive_id(self) -> None:
|
|
||||||
valid = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=1,
|
|
||||||
game_name="Game",
|
|
||||||
completionist_hours=1.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=123,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
details: list[dict[str, Any] | None] = [
|
|
||||||
{
|
|
||||||
"relationships": [
|
|
||||||
{"game_type": "dlc", "game_id": 0, "comp_100": 3600},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
by_app, ids = _collect_dlc_relationships(valid, details)
|
|
||||||
assert by_app[1] == [(0, 1.0)]
|
|
||||||
assert ids == []
|
|
||||||
|
|
||||||
def test_apply_dlc_leisure_overrides(self) -> None:
|
|
||||||
adjusted = _apply_dlc_leisure_overrides(
|
|
||||||
base_hours=6.0,
|
|
||||||
dlc_rels=[(10, 1.0), (11, 2.0)],
|
|
||||||
dlc_hours_by_id={10: 3.0},
|
|
||||||
)
|
|
||||||
assert adjusted == 8.0
|
|
||||||
|
|
||||||
def test_fetch_dlc_leisure_hours_empty(self) -> None:
|
|
||||||
async def _run() -> dict[int, float]:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
return await _fetch_dlc_leisure_hours(asyncio.Semaphore(1), session, [])
|
|
||||||
|
|
||||||
assert asyncio.run(_run()) == {}
|
|
||||||
|
|
||||||
def test_fetch_dlc_leisure_hours_skips_none_data(self) -> None:
|
|
||||||
async def _run() -> dict[int, float]:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
return await _fetch_dlc_leisure_hours(
|
|
||||||
asyncio.Semaphore(1),
|
|
||||||
session,
|
|
||||||
[1],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert asyncio.run(_run()) == {}
|
|
||||||
|
|
||||||
def test_fetch_dlc_leisure_hours_skips_non_positive_leisure(self) -> None:
|
|
||||||
bad_dlc_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 0, "comp_100": 0}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _run() -> dict[int, float]:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=bad_dlc_data,
|
|
||||||
):
|
|
||||||
return await _fetch_dlc_leisure_hours(
|
|
||||||
asyncio.Semaphore(1),
|
|
||||||
session,
|
|
||||||
[1],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert asyncio.run(_run()) == {}
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeTextResponse:
|
|
||||||
"""Async context manager mimicking aiohttp response for text."""
|
|
||||||
|
|
||||||
def __init__(self, status: int, text: str = "") -> None:
|
|
||||||
self.status = status
|
|
||||||
self._text = text
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args: object) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def text(self) -> str:
|
|
||||||
return self._text
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchDetailOne:
|
|
||||||
"""Tests for _fetch_detail_one."""
|
|
||||||
|
|
||||||
def test_success(self) -> None:
|
|
||||||
game_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 21243}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
next_data = {"props": {"pageProps": {"game": {"data": game_data}}}}
|
|
||||||
html = (
|
|
||||||
'<script id="__NEXT_DATA__" type="application/json">'
|
|
||||||
+ json.dumps(next_data)
|
|
||||||
+ "</script>"
|
|
||||||
)
|
|
||||||
resp = _FakeTextResponse(200, html)
|
|
||||||
session = MagicMock()
|
|
||||||
session.get = MagicMock(return_value=resp)
|
|
||||||
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
|
||||||
assert result == game_data
|
|
||||||
|
|
||||||
def test_non_200(self) -> None:
|
|
||||||
resp = _FakeTextResponse(404)
|
|
||||||
session = MagicMock()
|
|
||||||
session.get = MagicMock(return_value=resp)
|
|
||||||
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_client_error(self) -> None:
|
|
||||||
ctx = AsyncMock()
|
|
||||||
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
|
|
||||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
session = MagicMock()
|
|
||||||
session.get = MagicMock(return_value=ctx)
|
|
||||||
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_parse_failure(self) -> None:
|
|
||||||
resp = _FakeTextResponse(200, "<html>no script</html>")
|
|
||||||
session = MagicMock()
|
|
||||||
session.get = MagicMock(return_value=resp)
|
|
||||||
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchLeisureTimes:
|
|
||||||
"""Tests for _fetch_leisure_times."""
|
|
||||||
|
|
||||||
def test_updates_cache(self) -> None:
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=440,
|
|
||||||
game_name="TF2",
|
|
||||||
completionist_hours=50.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=12345,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
game_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 21243}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=game_data,
|
|
||||||
):
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, None))
|
|
||||||
assert cache[440] == round(21243 / 3600, 2)
|
|
||||||
assert results[0].completionist_hours == round(21243 / 3600, 2)
|
|
||||||
|
|
||||||
def test_no_valid_results(self) -> None:
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=440,
|
|
||||||
game_name="TF2",
|
|
||||||
completionist_hours=50.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=0,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, None))
|
|
||||||
assert cache == {}
|
|
||||||
|
|
||||||
def test_empty_results(self) -> None:
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
asyncio.run(_fetch_leisure_times([], cache, None))
|
|
||||||
assert cache == {}
|
|
||||||
|
|
||||||
def test_detail_returns_none(self) -> None:
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=440,
|
|
||||||
game_name="TF2",
|
|
||||||
completionist_hours=50.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=12345,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, None))
|
|
||||||
assert cache == {}
|
|
||||||
assert results[0].completionist_hours == 50.0
|
|
||||||
|
|
||||||
def test_negative_leisure(self) -> None:
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=440,
|
|
||||||
game_name="TF2",
|
|
||||||
completionist_hours=50.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=12345,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
game_data: dict[str, Any] = {"game": [], "relationships": []}
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=game_data,
|
|
||||||
):
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, None))
|
|
||||||
assert cache == {}
|
|
||||||
assert results[0].completionist_hours == 50.0
|
|
||||||
|
|
||||||
def test_with_progress_cb(self) -> None:
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=440,
|
|
||||||
game_name="TF2",
|
|
||||||
completionist_hours=50.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=12345,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
game_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 3600}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
cb = MagicMock()
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=game_data,
|
|
||||||
):
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, cb))
|
|
||||||
cb.assert_called_once()
|
|
||||||
|
|
||||||
def test_save_interval(self) -> None:
|
|
||||||
"""Trigger the _SAVE_INTERVAL branch in leisure fetching."""
|
|
||||||
from python_pkg.steam_backlog_enforcer.hltb import _SAVE_INTERVAL
|
|
||||||
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=i,
|
|
||||||
game_name=f"Game{i}",
|
|
||||||
completionist_hours=1.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=i + 1000,
|
|
||||||
)
|
|
||||||
for i in range(_SAVE_INTERVAL)
|
|
||||||
]
|
|
||||||
game_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 3600}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=game_data,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb.save_hltb_cache"
|
|
||||||
) as mock_save,
|
|
||||||
):
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, None))
|
|
||||||
mock_save.assert_called_once()
|
|
||||||
|
|
||||||
def test_dlc_detail_overrides_relationship_fallback(self) -> None:
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=1289310,
|
|
||||||
game_name="Helltaker",
|
|
||||||
completionist_hours=1.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=78118,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
base_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 21243, "comp_100": 6846}],
|
|
||||||
"relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}],
|
|
||||||
}
|
|
||||||
dlc_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 12298, "comp_100": 4075}],
|
|
||||||
"relationships": [],
|
|
||||||
}
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
side_effect=[base_data, dlc_data],
|
|
||||||
):
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, None))
|
|
||||||
|
|
||||||
expected = round((21243 + 12298) / 3600, 2)
|
|
||||||
assert cache[1289310] == expected
|
|
||||||
assert results[0].completionist_hours == expected
|
|
||||||
|
|
||||||
def test_missing_dlc_detail_keeps_relationship_fallback(self) -> None:
|
|
||||||
results = [
|
|
||||||
HLTBResult(
|
|
||||||
app_id=1289310,
|
|
||||||
game_name="Helltaker",
|
|
||||||
completionist_hours=1.0,
|
|
||||||
similarity=1.0,
|
|
||||||
hltb_game_id=78118,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
base_data: dict[str, Any] = {
|
|
||||||
"game": [{"comp_100_h": 21243, "comp_100": 6846}],
|
|
||||||
"relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}],
|
|
||||||
}
|
|
||||||
cache: dict[int, float] = {}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.steam_backlog_enforcer.hltb._fetch_detail_one",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
side_effect=[base_data, None],
|
|
||||||
):
|
|
||||||
asyncio.run(_fetch_leisure_times(results, cache, None))
|
|
||||||
|
|
||||||
expected = round((21243 + 4075) / 3600, 2)
|
|
||||||
assert cache[1289310] == expected
|
|
||||||
assert results[0].completionist_hours == expected
|
|
||||||
|
|||||||
378
python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py
Normal file
378
python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from python_pkg.steam_backlog_enforcer._hltb_detail import (
|
||||||
|
_apply_dlc_leisure_overrides,
|
||||||
|
_as_positive_int,
|
||||||
|
_collect_dlc_relationships,
|
||||||
|
_extract_base_leisure_hours,
|
||||||
|
_extract_dlc_relationships,
|
||||||
|
_fetch_detail_one,
|
||||||
|
_fetch_dlc_leisure_hours,
|
||||||
|
_fetch_leisure_times,
|
||||||
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer._hltb_types import _SAVE_INTERVAL, HLTBResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestInternalHelpers:
|
||||||
|
"""Tests for internal helper coverage."""
|
||||||
|
|
||||||
|
def test_as_positive_int_float(self) -> None:
|
||||||
|
assert _as_positive_int(1.9) == 1
|
||||||
|
|
||||||
|
def test_as_positive_int_invalid_type(self) -> None:
|
||||||
|
assert not _as_positive_int(object())
|
||||||
|
|
||||||
|
def test_extract_base_leisure_non_dict_game(self) -> None:
|
||||||
|
data: dict[str, Any] = {"game": [123]}
|
||||||
|
assert _extract_base_leisure_hours(data) == -1
|
||||||
|
|
||||||
|
def test_extract_dlc_relationships_skips_non_dict(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"relationships": [
|
||||||
|
"bad",
|
||||||
|
{"game_type": "dlc", "game_id": 7, "comp_100": 3600},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert _extract_dlc_relationships(data) == [(7, 1.0)]
|
||||||
|
|
||||||
|
def test_collect_dlc_relationships_ignores_non_positive_id(self) -> None:
|
||||||
|
valid = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=1,
|
||||||
|
game_name="Game",
|
||||||
|
completionist_hours=1.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=123,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
details: list[dict[str, Any] | None] = [
|
||||||
|
{
|
||||||
|
"relationships": [
|
||||||
|
{"game_type": "dlc", "game_id": 0, "comp_100": 3600},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
by_app, ids = _collect_dlc_relationships(valid, details)
|
||||||
|
assert by_app[1] == [(0, 1.0)]
|
||||||
|
assert ids == []
|
||||||
|
|
||||||
|
def test_apply_dlc_leisure_overrides(self) -> None:
|
||||||
|
adjusted = _apply_dlc_leisure_overrides(
|
||||||
|
base_hours=6.0,
|
||||||
|
dlc_rels=[(10, 1.0), (11, 2.0)],
|
||||||
|
dlc_hours_by_id={10: 3.0},
|
||||||
|
)
|
||||||
|
assert adjusted == 8.0
|
||||||
|
|
||||||
|
def test_fetch_dlc_leisure_hours_empty(self) -> None:
|
||||||
|
async def _run() -> dict[int, float]:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
return await _fetch_dlc_leisure_hours(asyncio.Semaphore(1), session, [])
|
||||||
|
|
||||||
|
assert asyncio.run(_run()) == {}
|
||||||
|
|
||||||
|
def test_fetch_dlc_leisure_hours_skips_none_data(self) -> None:
|
||||||
|
async def _run() -> dict[int, float]:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
return await _fetch_dlc_leisure_hours(
|
||||||
|
asyncio.Semaphore(1),
|
||||||
|
session,
|
||||||
|
[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert asyncio.run(_run()) == {}
|
||||||
|
|
||||||
|
def test_fetch_dlc_leisure_hours_skips_non_positive_leisure(self) -> None:
|
||||||
|
bad_dlc_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 0, "comp_100": 0}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _run() -> dict[int, float]:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=bad_dlc_data,
|
||||||
|
):
|
||||||
|
return await _fetch_dlc_leisure_hours(
|
||||||
|
asyncio.Semaphore(1),
|
||||||
|
session,
|
||||||
|
[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert asyncio.run(_run()) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTextResponse:
|
||||||
|
"""Async context manager mimicking aiohttp response for text."""
|
||||||
|
|
||||||
|
def __init__(self, status: int, text: str = "") -> None:
|
||||||
|
self.status = status
|
||||||
|
self._text = text
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def text(self) -> str:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchDetailOne:
|
||||||
|
"""Tests for _fetch_detail_one."""
|
||||||
|
|
||||||
|
def test_success(self) -> None:
|
||||||
|
game_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 21243}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
next_data = {"props": {"pageProps": {"game": {"data": game_data}}}}
|
||||||
|
html = (
|
||||||
|
'<script id="__NEXT_DATA__" type="application/json">'
|
||||||
|
+ json.dumps(next_data)
|
||||||
|
+ "</script>"
|
||||||
|
)
|
||||||
|
resp = _FakeTextResponse(200, html)
|
||||||
|
session = MagicMock()
|
||||||
|
session.get = MagicMock(return_value=resp)
|
||||||
|
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
||||||
|
assert result == game_data
|
||||||
|
|
||||||
|
def test_non_200(self) -> None:
|
||||||
|
resp = _FakeTextResponse(404)
|
||||||
|
session = MagicMock()
|
||||||
|
session.get = MagicMock(return_value=resp)
|
||||||
|
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_client_error(self) -> None:
|
||||||
|
ctx = AsyncMock()
|
||||||
|
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
|
||||||
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
session = MagicMock()
|
||||||
|
session.get = MagicMock(return_value=ctx)
|
||||||
|
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_parse_failure(self) -> None:
|
||||||
|
resp = _FakeTextResponse(200, "<html>no script</html>")
|
||||||
|
session = MagicMock()
|
||||||
|
session.get = MagicMock(return_value=resp)
|
||||||
|
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchLeisureTimes:
|
||||||
|
"""Tests for _fetch_leisure_times."""
|
||||||
|
|
||||||
|
def test_updates_cache(self) -> None:
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=440,
|
||||||
|
game_name="TF2",
|
||||||
|
completionist_hours=50.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=12345,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
game_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 21243}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=game_data,
|
||||||
|
):
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, None))
|
||||||
|
assert cache[440] == round(21243 / 3600, 2)
|
||||||
|
assert results[0].completionist_hours == round(21243 / 3600, 2)
|
||||||
|
|
||||||
|
def test_no_valid_results(self) -> None:
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=440,
|
||||||
|
game_name="TF2",
|
||||||
|
completionist_hours=50.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, None))
|
||||||
|
assert not cache
|
||||||
|
|
||||||
|
def test_empty_results(self) -> None:
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
asyncio.run(_fetch_leisure_times([], cache, None))
|
||||||
|
assert not cache
|
||||||
|
|
||||||
|
def test_detail_returns_none(self) -> None:
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=440,
|
||||||
|
game_name="TF2",
|
||||||
|
completionist_hours=50.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=12345,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, None))
|
||||||
|
assert not cache
|
||||||
|
assert results[0].completionist_hours == 50.0
|
||||||
|
|
||||||
|
def test_negative_leisure(self) -> None:
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=440,
|
||||||
|
game_name="TF2",
|
||||||
|
completionist_hours=50.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=12345,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
game_data: dict[str, Any] = {"game": [], "relationships": []}
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=game_data,
|
||||||
|
):
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, None))
|
||||||
|
assert not cache
|
||||||
|
assert results[0].completionist_hours == 50.0
|
||||||
|
|
||||||
|
def test_with_progress_cb(self) -> None:
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=440,
|
||||||
|
game_name="TF2",
|
||||||
|
completionist_hours=50.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=12345,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
game_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 3600}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
cb = MagicMock()
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=game_data,
|
||||||
|
):
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, cb))
|
||||||
|
cb.assert_called_once()
|
||||||
|
|
||||||
|
def test_save_interval(self) -> None:
|
||||||
|
"""Trigger the _SAVE_INTERVAL branch in leisure fetching."""
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=i,
|
||||||
|
game_name=f"Game{i}",
|
||||||
|
completionist_hours=1.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=i + 1000,
|
||||||
|
)
|
||||||
|
for i in range(_SAVE_INTERVAL)
|
||||||
|
]
|
||||||
|
game_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 3600}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=game_data,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail.save_hltb_cache"
|
||||||
|
) as mock_save,
|
||||||
|
):
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, None))
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
def test_dlc_detail_overrides_relationship_fallback(self) -> None:
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=1289310,
|
||||||
|
game_name="Helltaker",
|
||||||
|
completionist_hours=1.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=78118,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
base_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 21243, "comp_100": 6846}],
|
||||||
|
"relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}],
|
||||||
|
}
|
||||||
|
dlc_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 12298, "comp_100": 4075}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[base_data, dlc_data],
|
||||||
|
):
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, None))
|
||||||
|
|
||||||
|
expected = round((21243 + 12298) / 3600, 2)
|
||||||
|
assert cache[1289310] == expected
|
||||||
|
assert results[0].completionist_hours == expected
|
||||||
|
|
||||||
|
def test_missing_dlc_detail_keeps_relationship_fallback(self) -> None:
|
||||||
|
results = [
|
||||||
|
HLTBResult(
|
||||||
|
app_id=1289310,
|
||||||
|
game_name="Helltaker",
|
||||||
|
completionist_hours=1.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=78118,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
base_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 21243, "comp_100": 6846}],
|
||||||
|
"relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}],
|
||||||
|
}
|
||||||
|
cache: dict[int, float] = {}
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[base_data, None],
|
||||||
|
):
|
||||||
|
asyncio.run(_fetch_leisure_times(results, cache, None))
|
||||||
|
|
||||||
|
expected = round((21243 + 4075) / 3600, 2)
|
||||||
|
assert cache[1289310] == expected
|
||||||
|
assert results[0].completionist_hours == expected
|
||||||
440
python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py
Normal file
440
python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
"""Tests for HLTB search, batch-fetch, and page parsing — part 2."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from python_pkg.steam_backlog_enforcer._hltb_detail import (
|
||||||
|
_extract_leisure_hours,
|
||||||
|
_parse_game_page,
|
||||||
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer.hltb import (
|
||||||
|
_SAVE_INTERVAL,
|
||||||
|
HLTBResult,
|
||||||
|
_AuthInfo,
|
||||||
|
_fetch_batch,
|
||||||
|
_search_one,
|
||||||
|
_SearchCtx,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
"""Async context manager mimicking aiohttp response."""
|
||||||
|
|
||||||
|
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
|
||||||
|
self.status = status
|
||||||
|
self._json_data = json_data or {}
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def json(self) -> dict[str, Any]:
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(resp: _FakeResponse) -> MagicMock:
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.return_value = resp
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ctx(
|
||||||
|
session: MagicMock,
|
||||||
|
*,
|
||||||
|
cache: dict[int, float] | None = None,
|
||||||
|
progress_cb: Callable[..., object] | None = None,
|
||||||
|
) -> _SearchCtx:
|
||||||
|
return _SearchCtx(
|
||||||
|
session=session,
|
||||||
|
search_url="https://example.com/search",
|
||||||
|
headers={},
|
||||||
|
cache=cache if cache is not None else {},
|
||||||
|
counter={"done": 0, "found": 0},
|
||||||
|
total=1,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchOne:
|
||||||
|
"""Tests for _search_one."""
|
||||||
|
|
||||||
|
def test_found(self) -> None:
|
||||||
|
resp = _FakeResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_name": "TF2",
|
||||||
|
"game_alias": "",
|
||||||
|
"comp_100": 180000,
|
||||||
|
"game_id": 12345,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is not None
|
||||||
|
assert result.app_id == 440
|
||||||
|
|
||||||
|
def test_not_found(self) -> None:
|
||||||
|
resp = _FakeResponse(200, {"data": []})
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is None
|
||||||
|
assert ctx.cache[440] == -1
|
||||||
|
|
||||||
|
def test_error(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.side_effect = aiohttp.ClientError("fail")
|
||||||
|
ctx = _make_ctx(session)
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_non_200(self) -> None:
|
||||||
|
resp = _FakeResponse(500)
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_with_progress_cb(self) -> None:
|
||||||
|
resp = _FakeResponse(200, {"data": []})
|
||||||
|
cb = MagicMock()
|
||||||
|
ctx = _make_ctx(_make_session(resp), progress_cb=cb)
|
||||||
|
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
cb.assert_called_once()
|
||||||
|
|
||||||
|
def test_low_similarity_skipped(self) -> None:
|
||||||
|
resp = _FakeResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_name": "Completely Different Name",
|
||||||
|
"game_alias": "",
|
||||||
|
"comp_100": 3600,
|
||||||
|
"game_id": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_zero_comp_100_skipped(self) -> None:
|
||||||
|
resp = _FakeResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_name": "TF2",
|
||||||
|
"game_alias": "",
|
||||||
|
"comp_100": 0,
|
||||||
|
"game_id": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_alias_match(self) -> None:
|
||||||
|
resp = _FakeResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_name": "Team Fortress 2",
|
||||||
|
"game_alias": "TF2",
|
||||||
|
"comp_100": 180000,
|
||||||
|
"game_id": 12345,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_full_edition_colon(self) -> None:
|
||||||
|
resp = _FakeResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_name": "TF2: Complete",
|
||||||
|
"game_alias": "",
|
||||||
|
"comp_100": 180000,
|
||||||
|
"game_id": 99,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_full_edition_dash(self) -> None:
|
||||||
|
resp = _FakeResponse(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_name": "TF2 - Complete",
|
||||||
|
"game_alias": "",
|
||||||
|
"comp_100": 180000,
|
||||||
|
"game_id": 99,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_save_interval(self) -> None:
|
||||||
|
"""Trigger the _SAVE_INTERVAL branch."""
|
||||||
|
resp = _FakeResponse(200, {"data": []})
|
||||||
|
ctx = _make_ctx(_make_session(resp))
|
||||||
|
# Set done to one less than _SAVE_INTERVAL so it triggers save
|
||||||
|
|
||||||
|
ctx.counter["done"] = _SAVE_INTERVAL - 1
|
||||||
|
with patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb.save_hltb_cache"
|
||||||
|
) as mock_save:
|
||||||
|
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchBatchHltb:
|
||||||
|
"""Tests for _fetch_batch (the hltb version)."""
|
||||||
|
|
||||||
|
def test_no_auth(self) -> None:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
||||||
|
return_value="https://example.com",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_with_auth(self) -> None:
|
||||||
|
auth = _AuthInfo("token123", "ign_x", "ff")
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
||||||
|
return_value="https://example.com",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=auth,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._search_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=HLTBResult(
|
||||||
|
app_id=440,
|
||||||
|
game_name="TF2",
|
||||||
|
completionist_hours=50.0,
|
||||||
|
similarity=1.0,
|
||||||
|
hltb_game_id=12345,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._fetch_leisure_times",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
def test_with_auth_no_hp(self) -> None:
|
||||||
|
auth = _AuthInfo("tok123")
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
||||||
|
return_value="https://example.com",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=auth,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._search_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._fetch_leisure_times",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_filters_none_results(self) -> None:
|
||||||
|
auth = _AuthInfo("tok123")
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
|
||||||
|
return_value="https://example.com",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=auth,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._search_one",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"python_pkg.steam_backlog_enforcer.hltb._fetch_leisure_times",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseGamePage:
|
||||||
|
"""Tests for _parse_game_page."""
|
||||||
|
|
||||||
|
def test_valid_html(self) -> None:
|
||||||
|
game_data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
next_data = {
|
||||||
|
"props": {"pageProps": {"game": {"data": game_data}}},
|
||||||
|
}
|
||||||
|
html = (
|
||||||
|
'<html><script id="__NEXT_DATA__" type="application/json">'
|
||||||
|
+ json.dumps(next_data)
|
||||||
|
+ "</script></html>"
|
||||||
|
)
|
||||||
|
assert _parse_game_page(html) == game_data
|
||||||
|
|
||||||
|
def test_no_script_tag(self) -> None:
|
||||||
|
assert _parse_game_page("<html></html>") is None
|
||||||
|
|
||||||
|
def test_bad_json(self) -> None:
|
||||||
|
html = '<script id="__NEXT_DATA__" type="application/json">{not json}</script>'
|
||||||
|
assert _parse_game_page(html) is None
|
||||||
|
|
||||||
|
def test_missing_keys(self) -> None:
|
||||||
|
html = (
|
||||||
|
'<script id="__NEXT_DATA__" type="application/json">{"props": {}}</script>'
|
||||||
|
)
|
||||||
|
assert _parse_game_page(html) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractLeisureHours:
|
||||||
|
"""Tests for _extract_leisure_hours."""
|
||||||
|
|
||||||
|
def test_leisure_time_only(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == round(21243 / 3600, 2)
|
||||||
|
|
||||||
|
def test_leisure_with_dlc(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
|
||||||
|
"relationships": [
|
||||||
|
{"game_type": "dlc", "comp_100": 12298},
|
||||||
|
{"game_type": "dlc", "comp_100": 3600},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2)
|
||||||
|
|
||||||
|
def test_fallback_to_comp_100(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100": 7200}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == round(7200 / 3600, 2)
|
||||||
|
|
||||||
|
def test_no_game_data(self) -> None:
|
||||||
|
assert _extract_leisure_hours({"game": [], "relationships": []}) == -1
|
||||||
|
|
||||||
|
def test_zero_leisure(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 0, "comp_100": 0}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == -1
|
||||||
|
|
||||||
|
def test_no_game_key(self) -> None:
|
||||||
|
assert _extract_leisure_hours({"relationships": []}) == -1
|
||||||
|
|
||||||
|
def test_non_dlc_relationship_ignored(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 3600}],
|
||||||
|
"relationships": [
|
||||||
|
{"game_type": "game", "comp_100": 9999},
|
||||||
|
{"game_type": "dlc", "comp_100": 1800},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2)
|
||||||
|
|
||||||
|
def test_dlc_zero_comp_100_skipped(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 3600}],
|
||||||
|
"relationships": [
|
||||||
|
{"game_type": "dlc", "comp_100": 0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == round(3600 / 3600, 2)
|
||||||
|
|
||||||
|
def test_negative_leisure(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": -1, "comp_100": -1}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == -1
|
||||||
|
|
||||||
|
def test_string_numeric_fields(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": "7200", "comp_100": "3600"}],
|
||||||
|
"relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2)
|
||||||
|
|
||||||
|
def test_bad_string_falls_back_to_comp_100(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": "bad", "comp_100": "3600"}],
|
||||||
|
"relationships": [],
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == 1.0
|
||||||
|
|
||||||
|
def test_relationships_not_list(self) -> None:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"game": [{"comp_100_h": 3600}],
|
||||||
|
"relationships": "not-a-list",
|
||||||
|
}
|
||||||
|
assert _extract_leisure_hours(data) == 1.0
|
||||||
@ -7,7 +7,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from python_pkg.steam_backlog_enforcer.config import Config, State
|
from python_pkg.steam_backlog_enforcer.config import Config, State
|
||||||
from python_pkg.steam_backlog_enforcer.main import (
|
from python_pkg.steam_backlog_enforcer.main import (
|
||||||
_try_reassign_shorter_game,
|
|
||||||
cmd_buy_dlc,
|
cmd_buy_dlc,
|
||||||
cmd_hide,
|
cmd_hide,
|
||||||
cmd_install,
|
cmd_install,
|
||||||
@ -20,7 +19,6 @@ from python_pkg.steam_backlog_enforcer.main import (
|
|||||||
cmd_unhide,
|
cmd_unhide,
|
||||||
cmd_uninstall,
|
cmd_uninstall,
|
||||||
)
|
)
|
||||||
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
|
|
||||||
|
|
||||||
PKG = "python_pkg.steam_backlog_enforcer.main"
|
PKG = "python_pkg.steam_backlog_enforcer.main"
|
||||||
|
|
||||||
@ -379,145 +377,3 @@ class TestCmdUnhide:
|
|||||||
patch(f"{PKG}._echo"),
|
patch(f"{PKG}._echo"),
|
||||||
):
|
):
|
||||||
cmd_unhide(Config(), State())
|
cmd_unhide(Config(), State())
|
||||||
|
|
||||||
|
|
||||||
class TestTryReassignShorterGame:
|
|
||||||
"""Tests for _try_reassign_shorter_game."""
|
|
||||||
|
|
||||||
def test_no_snapshot(self) -> None:
|
|
||||||
with patch(f"{PKG}.load_snapshot", return_value=None):
|
|
||||||
assert not _try_reassign_shorter_game({}, 1, 10.0, State(), Config())
|
|
||||||
|
|
||||||
def test_no_shorter_candidate(self) -> None:
|
|
||||||
snap = [_snap(1, "G", 10, 5, 10.0), _snap(2, "H", 10, 5, -1)]
|
|
||||||
with (
|
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
||||||
patch(f"{PKG}._echo"),
|
|
||||||
):
|
|
||||||
result = _try_reassign_shorter_game(
|
|
||||||
{1: 10.0},
|
|
||||||
1,
|
|
||||||
10.0,
|
|
||||||
State(),
|
|
||||||
Config(),
|
|
||||||
)
|
|
||||||
assert not result
|
|
||||||
|
|
||||||
def test_reassigns(self) -> None:
|
|
||||||
snap = [
|
|
||||||
_snap(1, "Long", 10, 5, 100.0),
|
|
||||||
_snap(2, "Short", 10, 5, 5.0),
|
|
||||||
]
|
|
||||||
state = State(current_app_id=1, current_game_name="Long")
|
|
||||||
short_game = GameInfo(
|
|
||||||
app_id=2,
|
|
||||||
name="Short",
|
|
||||||
total_achievements=10,
|
|
||||||
unlocked_achievements=5,
|
|
||||||
playtime_minutes=60,
|
|
||||||
completionist_hours=5.0,
|
|
||||||
)
|
|
||||||
with (
|
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
||||||
patch(f"{PKG}._echo"),
|
|
||||||
patch(
|
|
||||||
f"{PKG}._pick_playable_candidate",
|
|
||||||
return_value=short_game,
|
|
||||||
),
|
|
||||||
patch(f"{PKG}.pick_next_game"),
|
|
||||||
):
|
|
||||||
result = _try_reassign_shorter_game(
|
|
||||||
{1: 100.0, 2: 5.0},
|
|
||||||
1,
|
|
||||||
100.0,
|
|
||||||
state,
|
|
||||||
Config(),
|
|
||||||
)
|
|
||||||
assert result
|
|
||||||
|
|
||||||
def test_playable_none(self) -> None:
|
|
||||||
snap = [
|
|
||||||
_snap(1, "Long", 10, 5, 100.0),
|
|
||||||
_snap(2, "Short", 10, 5, 5.0),
|
|
||||||
]
|
|
||||||
with (
|
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
||||||
patch(f"{PKG}._pick_playable_candidate", return_value=None),
|
|
||||||
patch(f"{PKG}._echo"),
|
|
||||||
):
|
|
||||||
result = _try_reassign_shorter_game(
|
|
||||||
{1: 100.0, 2: 5.0},
|
|
||||||
1,
|
|
||||||
100.0,
|
|
||||||
State(),
|
|
||||||
Config(),
|
|
||||||
)
|
|
||||||
assert not result
|
|
||||||
|
|
||||||
def test_playable_longer(self) -> None:
|
|
||||||
"""Playable candidate is longer than current — no reassign."""
|
|
||||||
snap = [
|
|
||||||
_snap(1, "Short", 10, 5, 10.0),
|
|
||||||
_snap(2, "Long", 10, 5, 200.0),
|
|
||||||
]
|
|
||||||
long_game = GameInfo(
|
|
||||||
app_id=2,
|
|
||||||
name="Long",
|
|
||||||
total_achievements=10,
|
|
||||||
unlocked_achievements=5,
|
|
||||||
playtime_minutes=60,
|
|
||||||
completionist_hours=200.0,
|
|
||||||
)
|
|
||||||
with (
|
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
||||||
patch(f"{PKG}._pick_playable_candidate", return_value=long_game),
|
|
||||||
patch(f"{PKG}._echo"),
|
|
||||||
):
|
|
||||||
result = _try_reassign_shorter_game(
|
|
||||||
{1: 10.0, 2: 200.0},
|
|
||||||
1,
|
|
||||||
10.0,
|
|
||||||
State(),
|
|
||||||
Config(),
|
|
||||||
)
|
|
||||||
assert not result
|
|
||||||
|
|
||||||
def test_refreshes_stale_shorter_snapshot_entry(self) -> None:
|
|
||||||
"""Uncached shorter snapshot candidates are refreshed before reassigning."""
|
|
||||||
snap = [
|
|
||||||
_snap(1, "Current", 10, 5, 20.1),
|
|
||||||
_snap(2, "Lacuna", 10, 0, 0.9),
|
|
||||||
]
|
|
||||||
state = State(current_app_id=1, current_game_name="Current")
|
|
||||||
refreshed_short = GameInfo(
|
|
||||||
app_id=2,
|
|
||||||
name="Lacuna",
|
|
||||||
total_achievements=10,
|
|
||||||
unlocked_achievements=0,
|
|
||||||
playtime_minutes=60,
|
|
||||||
completionist_hours=18.8,
|
|
||||||
)
|
|
||||||
with (
|
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
||||||
patch(
|
|
||||||
f"{PKG}.fetch_hltb_times_cached",
|
|
||||||
return_value={2: 18.8},
|
|
||||||
) as mock_fetch_hltb,
|
|
||||||
patch(
|
|
||||||
f"{PKG}._pick_playable_candidate",
|
|
||||||
return_value=refreshed_short,
|
|
||||||
) as mock_pick_playable,
|
|
||||||
patch(f"{PKG}.pick_next_game"),
|
|
||||||
patch(f"{PKG}._echo"),
|
|
||||||
):
|
|
||||||
result = _try_reassign_shorter_game(
|
|
||||||
{1: 20.1},
|
|
||||||
1,
|
|
||||||
20.1,
|
|
||||||
state,
|
|
||||||
Config(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result
|
|
||||||
mock_fetch_hltb.assert_called_once_with([(2, "Lacuna")])
|
|
||||||
mock_pick_playable.assert_called_once()
|
|
||||||
|
|||||||
@ -8,15 +8,16 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from python_pkg.steam_backlog_enforcer.config import Config, State
|
from python_pkg.steam_backlog_enforcer._cmd_done import (
|
||||||
from python_pkg.steam_backlog_enforcer.main import (
|
|
||||||
_enforce_on_done,
|
_enforce_on_done,
|
||||||
_finalize_completion,
|
_finalize_completion,
|
||||||
cmd_done,
|
cmd_done,
|
||||||
main,
|
|
||||||
)
|
)
|
||||||
|
from python_pkg.steam_backlog_enforcer.config import Config, State
|
||||||
|
from python_pkg.steam_backlog_enforcer.main import main
|
||||||
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
|
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
|
||||||
|
|
||||||
|
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
|
||||||
PKG = "python_pkg.steam_backlog_enforcer.main"
|
PKG = "python_pkg.steam_backlog_enforcer.main"
|
||||||
|
|
||||||
|
|
||||||
@ -45,12 +46,12 @@ class TestFinalizeCompletion:
|
|||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
||||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||||
patch(f"{PKG}.hide_other_games", return_value=2),
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=2),
|
||||||
patch(f"{PKG}.send_notification"),
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||||
patch.object(State, "save"),
|
patch.object(State, "save"),
|
||||||
):
|
):
|
||||||
|
|
||||||
@ -70,8 +71,8 @@ class TestFinalizeCompletion:
|
|||||||
config = Config()
|
config = Config()
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_snapshot", return_value=None),
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=None),
|
||||||
patch.object(State, "save"),
|
patch.object(State, "save"),
|
||||||
):
|
):
|
||||||
_finalize_completion(config, state, "G", 1)
|
_finalize_completion(config, state, "G", 1)
|
||||||
@ -82,9 +83,9 @@ class TestFinalizeCompletion:
|
|||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
snap = [_snap(1, "G", 10, 10)]
|
snap = [_snap(1, "G", 10, 10)]
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
||||||
patch.object(State, "save"),
|
patch.object(State, "save"),
|
||||||
):
|
):
|
||||||
|
|
||||||
@ -103,11 +104,11 @@ class TestFinalizeCompletion:
|
|||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
snap = [_snap(2, "Next", 10, 0)]
|
snap = [_snap(2, "Next", 10, 0)]
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
||||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
|
||||||
patch(f"{PKG}.send_notification"),
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||||
patch.object(State, "save"),
|
patch.object(State, "save"),
|
||||||
):
|
):
|
||||||
|
|
||||||
@ -127,12 +128,12 @@ class TestFinalizeCompletion:
|
|||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
snap = [_snap(2, "Next", 10, 0)]
|
snap = [_snap(2, "Next", 10, 0)]
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
||||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||||
patch(f"{PKG}.hide_other_games", return_value=0),
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0),
|
||||||
patch(f"{PKG}.send_notification"),
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||||
patch.object(State, "save"),
|
patch.object(State, "save"),
|
||||||
):
|
):
|
||||||
|
|
||||||
@ -168,14 +169,14 @@ class TestFinalizeCompletion:
|
|||||||
s.current_app_id = None
|
s.current_app_id = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||||
patch(f"{PKG}.load_hltb_cache", return_value={2: 20.05}),
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={2: 20.05}),
|
||||||
patch(
|
patch(
|
||||||
f"{PKG}.fetch_hltb_times_cached",
|
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||||
return_value={3: 18.81},
|
return_value={3: 18.81},
|
||||||
) as mock_fetch_hltb,
|
) as mock_fetch_hltb,
|
||||||
patch(f"{PKG}.pick_next_game", side_effect=capture_pick),
|
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=capture_pick),
|
||||||
patch.object(State, "save"),
|
patch.object(State, "save"),
|
||||||
):
|
):
|
||||||
_finalize_completion(config, state, "G", 1)
|
_finalize_completion(config, state, "G", 1)
|
||||||
@ -198,13 +199,13 @@ class TestEnforceOnDone:
|
|||||||
)
|
)
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(
|
patch(
|
||||||
f"{PKG}.enforce_allowed_game",
|
f"{CMD_DONE_PKG}.enforce_allowed_game",
|
||||||
return_value=[(1234, 999)],
|
return_value=[(1234, 999)],
|
||||||
),
|
),
|
||||||
patch(f"{PKG}.uninstall_other_games", return_value=2),
|
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2),
|
||||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||||
):
|
):
|
||||||
_enforce_on_done(config, state)
|
_enforce_on_done(config, state)
|
||||||
|
|
||||||
@ -215,10 +216,10 @@ class TestEnforceOnDone:
|
|||||||
)
|
)
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.enforce_allowed_game", return_value=[]),
|
patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]),
|
||||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0),
|
||||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||||
):
|
):
|
||||||
_enforce_on_done(config, state)
|
_enforce_on_done(config, state)
|
||||||
|
|
||||||
@ -230,9 +231,9 @@ class TestEnforceOnDone:
|
|||||||
)
|
)
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
||||||
patch(f"{PKG}.install_game") as mock_install,
|
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||||
):
|
):
|
||||||
_enforce_on_done(config, state)
|
_enforce_on_done(config, state)
|
||||||
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
|
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
|
||||||
@ -242,7 +243,7 @@ class TestCmdDone:
|
|||||||
"""Tests for cmd_done."""
|
"""Tests for cmd_done."""
|
||||||
|
|
||||||
def test_no_game_assigned(self) -> None:
|
def test_no_game_assigned(self) -> None:
|
||||||
with patch(f"{PKG}._echo") as mock_echo:
|
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
|
||||||
cmd_done(Config(), State())
|
cmd_done(Config(), State())
|
||||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||||
|
|
||||||
@ -251,8 +252,8 @@ class TestCmdDone:
|
|||||||
mock_client.refresh_single_game.return_value = None
|
mock_client.refresh_single_game.return_value = None
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
|
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
):
|
):
|
||||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||||
|
|
||||||
@ -268,11 +269,11 @@ class TestCmdDone:
|
|||||||
mock_client.refresh_single_game.return_value = game
|
mock_client.refresh_single_game.return_value = game
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
|
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_hltb_cache", return_value={1: 20.0}),
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
|
||||||
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
|
patch(f"{CMD_DONE_PKG}._try_reassign_shorter_game", return_value=False),
|
||||||
patch(f"{PKG}._enforce_on_done"),
|
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||||
):
|
):
|
||||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||||
|
|
||||||
@ -288,11 +289,11 @@ class TestCmdDone:
|
|||||||
mock_client.refresh_single_game.return_value = game
|
mock_client.refresh_single_game.return_value = game
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
|
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_hltb_cache", return_value={1: 10.0}),
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
|
||||||
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
|
patch(f"{CMD_DONE_PKG}._try_reassign_shorter_game", return_value=False),
|
||||||
patch(f"{PKG}._finalize_completion") as mock_final,
|
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
|
||||||
):
|
):
|
||||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||||
mock_final.assert_called_once()
|
mock_final.assert_called_once()
|
||||||
@ -309,15 +310,15 @@ class TestCmdDone:
|
|||||||
mock_client.refresh_single_game.return_value = game
|
mock_client.refresh_single_game.return_value = game
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
|
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
|
||||||
patch(
|
patch(
|
||||||
f"{PKG}.fetch_hltb_times_cached",
|
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||||
return_value={1: 15.0},
|
return_value={1: 15.0},
|
||||||
),
|
),
|
||||||
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
|
patch(f"{CMD_DONE_PKG}._try_reassign_shorter_game", return_value=False),
|
||||||
patch(f"{PKG}._enforce_on_done"),
|
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||||
):
|
):
|
||||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||||
|
|
||||||
@ -334,11 +335,11 @@ class TestCmdDone:
|
|||||||
mock_client.refresh_single_game.return_value = game
|
mock_client.refresh_single_game.return_value = game
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
|
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_hltb_cache", return_value={1: -1.0}),
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
|
||||||
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
|
patch(f"{CMD_DONE_PKG}._try_reassign_shorter_game", return_value=False),
|
||||||
patch(f"{PKG}._enforce_on_done"),
|
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||||
):
|
):
|
||||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||||
|
|
||||||
@ -354,10 +355,10 @@ class TestCmdDone:
|
|||||||
mock_client.refresh_single_game.return_value = game
|
mock_client.refresh_single_game.return_value = game
|
||||||
state = State(current_app_id=1, current_game_name="G")
|
state = State(current_app_id=1, current_game_name="G")
|
||||||
with (
|
with (
|
||||||
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
|
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||||
patch(f"{PKG}._echo"),
|
patch(f"{CMD_DONE_PKG}._echo"),
|
||||||
patch(f"{PKG}.load_hltb_cache", return_value={1: 50.0}),
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
|
||||||
patch(f"{PKG}._try_reassign_shorter_game", return_value=True),
|
patch(f"{CMD_DONE_PKG}._try_reassign_shorter_game", return_value=True),
|
||||||
):
|
):
|
||||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user