mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 12:03:14 +02:00
Add tests and fix pre-commit issues across all projects
- C/lichess_random_engine, vocabulary_curve, misc/split, 1dvelocitysimulator, opening_learner: test suites added - CPP/miscelanious: tests added - TS/battery-status, champions_leauge_scores, two-inputs: tests added - python_pkg/fm24_searcher, wake_alarm: new packages added - Fix ruff/cppcheck/eslint/clang-format failures - Update .gitignore for C/C++ build artifacts
This commit is contained in:
parent
ee41f014b2
commit
01091c09ce
@ -1,19 +1,47 @@
|
||||
CC := gcc
|
||||
CFLAGS := -O2 -Wall -Wextra -std=c11 -D_DEFAULT_SOURCE
|
||||
LDFLAGS :=
|
||||
LDFLAGS := -lm
|
||||
|
||||
SRC := main.c
|
||||
SRC := main.c physics.c
|
||||
BIN := 1dvelocitysimulator
|
||||
|
||||
TEST_SRC := test_physics.c physics.c
|
||||
TEST_BIN := test_physics
|
||||
|
||||
COV_CFLAGS := -Wall -Wextra -std=c11 -D_DEFAULT_SOURCE -DTESTING --coverage -g -O0
|
||||
COV_LDFLAGS := -lm
|
||||
|
||||
all: $(BIN)
|
||||
|
||||
$(BIN): $(SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
$(BIN): $(SRC) physics.h
|
||||
$(CC) $(CFLAGS) -o $@ $(SRC) $(LDFLAGS)
|
||||
|
||||
run: $(BIN)
|
||||
./$(BIN)
|
||||
|
||||
clean:
|
||||
rm -f $(BIN)
|
||||
test: $(TEST_BIN)
|
||||
./$(TEST_BIN)
|
||||
|
||||
.PHONY: all run clean
|
||||
$(TEST_BIN): $(TEST_SRC) physics.h
|
||||
$(CC) $(CFLAGS) -DTESTING -o $@ $(TEST_SRC) $(LDFLAGS)
|
||||
|
||||
coverage:
|
||||
$(CC) $(COV_CFLAGS) -o $(TEST_BIN) $(TEST_SRC) $(COV_LDFLAGS)
|
||||
./$(TEST_BIN)
|
||||
lcov --capture --directory . --output-file coverage.info --rc branch_coverage=1
|
||||
lcov --remove coverage.info '/usr/*' --output-file coverage.info \
|
||||
--rc branch_coverage=1 --ignore-errors unused
|
||||
@LINE_PCT=$$(lcov --summary coverage.info 2>&1 | grep -oP 'lines\.*:\s*\K[0-9.]+'); \
|
||||
echo "Line coverage: $${LINE_PCT}%"; \
|
||||
if [ "$$(echo "$${LINE_PCT} < 100.0" | bc -l)" = "1" ]; then \
|
||||
echo "FAIL: Line coverage $${LINE_PCT}% is below 100%"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "OK: 100% line coverage achieved"; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
rm -f $(BIN) $(TEST_BIN) *.gcda *.gcno *.gcov coverage.info
|
||||
rm -rf coverage_html
|
||||
|
||||
.PHONY: all run test coverage clean
|
||||
|
||||
@ -1,180 +1,4 @@
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#define SLEEP_MS(ms) Sleep(ms)
|
||||
#define CLEAR_SCREEN() system("CLS")
|
||||
#define PAUSE() system("PAUSE")
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#define SLEEP_MS(ms) usleep((ms) * 1000U)
|
||||
#define CLEAR_SCREEN() system("clear")
|
||||
#define PAUSE() \
|
||||
do \
|
||||
{ \
|
||||
printf("Press Enter to continue..."); \
|
||||
getchar(); \
|
||||
} while (0)
|
||||
#endif
|
||||
|
||||
#define LINE_LENGTH 100
|
||||
|
||||
void C()
|
||||
{
|
||||
printf("\nCheck\n");
|
||||
return;
|
||||
}
|
||||
|
||||
void printAcceleration(int acceleration)
|
||||
{
|
||||
printf("The value of acceleration is: %d\n", acceleration);
|
||||
PAUSE();
|
||||
return;
|
||||
}
|
||||
|
||||
void pauseSystem() { PAUSE(); }
|
||||
|
||||
void clearScreen()
|
||||
{
|
||||
CLEAR_SCREEN();
|
||||
return;
|
||||
}
|
||||
|
||||
void pauseForASecond()
|
||||
{
|
||||
SLEEP_MS(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
void pauseForGivenTime(float given_time)
|
||||
{
|
||||
SLEEP_MS((unsigned int)fabs(given_time * 1000));
|
||||
return;
|
||||
}
|
||||
|
||||
float calculateVelocity(float starting_velocity, unsigned int physics_time, int *acceleration)
|
||||
{
|
||||
// cppcheck-suppress nullPointer
|
||||
return (*acceleration) * physics_time + starting_velocity;
|
||||
}
|
||||
|
||||
int calculateDisplacement(float starting_velocity, int *acceleration, unsigned int physics_time)
|
||||
{
|
||||
// cppcheck-suppress nullPointer
|
||||
// cppcheck-suppress ctunullpointer
|
||||
return starting_velocity * physics_time + ((1 / 2) * (*acceleration) * (physics_time ^ 2));
|
||||
}
|
||||
|
||||
void printXPosition(int position)
|
||||
{
|
||||
printf("\nx position is: %d\n", position);
|
||||
return;
|
||||
}
|
||||
|
||||
void printClock(unsigned int *time)
|
||||
{
|
||||
printf("%u seconds passed\n", *time);
|
||||
return;
|
||||
}
|
||||
|
||||
float calculateStopTime(float velocity) { return 1 / velocity; }
|
||||
|
||||
void printLine(int position)
|
||||
{
|
||||
clearScreen();
|
||||
for (int i = -(LINE_LENGTH / 2); i < LINE_LENGTH / 2; i++)
|
||||
{
|
||||
if (i == position)
|
||||
printf("x");
|
||||
else
|
||||
printf("-");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void printVelocity(float velocity)
|
||||
{
|
||||
printf("Velocity is: %f\n", velocity);
|
||||
return;
|
||||
}
|
||||
|
||||
int calculateTimePassed(float velocity)
|
||||
{
|
||||
if (velocity >= 1 || velocity <= -1)
|
||||
return 1;
|
||||
else
|
||||
{
|
||||
printf("Time passed is: %f\n", fabs(1 / velocity));
|
||||
return fabs(1 / velocity);
|
||||
}
|
||||
}
|
||||
|
||||
void printAllInfo(int position, unsigned int *time, float *velocity)
|
||||
{
|
||||
pauseForGivenTime(calculateStopTime(*velocity));
|
||||
printLine(position);
|
||||
printXPosition(position);
|
||||
*time += calculateTimePassed(*velocity);
|
||||
printClock(time);
|
||||
printVelocity(*velocity);
|
||||
// pauseForASecond();
|
||||
return;
|
||||
}
|
||||
|
||||
float chooseVelocity()
|
||||
{
|
||||
float velocity;
|
||||
printf("Write velocity of the object in m / s: ");
|
||||
scanf("%f", &velocity);
|
||||
return velocity;
|
||||
}
|
||||
|
||||
int chooseAcceleration()
|
||||
{
|
||||
int acceleration;
|
||||
printf("Choose acceleration of the object in m / (s ^ 2):");
|
||||
scanf("%d", &acceleration);
|
||||
return acceleration;
|
||||
}
|
||||
|
||||
int outOfLine(int position)
|
||||
{
|
||||
if ((position < LINE_LENGTH / 2) && (position > -1 * (LINE_LENGTH / 2)))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
return 1;
|
||||
}
|
||||
|
||||
void moveUntillOutOfLine(int position, unsigned int *time)
|
||||
{
|
||||
while (!outOfLine(position))
|
||||
{
|
||||
float velocity = chooseVelocity();
|
||||
float *Pvelocity = &velocity;
|
||||
position += calculateDisplacement(velocity, 0, 1);
|
||||
printAllInfo(position, time, Pvelocity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void moveUntillOutOfVelocity(int position, int *acceleration, unsigned int *time)
|
||||
{
|
||||
float velocity = 0;
|
||||
float *Pvelocity = &velocity;
|
||||
while (!outOfLine(position))
|
||||
{
|
||||
position += calculateDisplacement(velocity, acceleration, 1);
|
||||
printXPosition(position);
|
||||
pauseSystem();
|
||||
velocity = calculateVelocity(velocity, 1, acceleration);
|
||||
printAllInfo(position, time, Pvelocity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
#include "physics.h"
|
||||
|
||||
int main()
|
||||
{
|
||||
|
||||
160
C/1dvelocitysimulator/physics.c
Normal file
160
C/1dvelocitysimulator/physics.c
Normal file
@ -0,0 +1,160 @@
|
||||
#include "physics.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
void C()
|
||||
{
|
||||
printf("\nCheck\n");
|
||||
return;
|
||||
}
|
||||
|
||||
void printAcceleration(int acceleration)
|
||||
{
|
||||
printf("The value of acceleration is: %d\n", acceleration);
|
||||
PAUSE();
|
||||
return;
|
||||
}
|
||||
|
||||
void pauseSystem() { PAUSE(); }
|
||||
|
||||
void clearScreen()
|
||||
{
|
||||
CLEAR_SCREEN();
|
||||
return;
|
||||
}
|
||||
|
||||
void pauseForASecond()
|
||||
{
|
||||
SLEEP_MS(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
void pauseForGivenTime(float given_time)
|
||||
{
|
||||
SLEEP_MS((unsigned int)fabs((double)given_time * 1000));
|
||||
return;
|
||||
}
|
||||
|
||||
float calculateVelocity(float starting_velocity, unsigned int physics_time, int *acceleration)
|
||||
{
|
||||
// cppcheck-suppress nullPointer
|
||||
return (*acceleration) * physics_time + starting_velocity;
|
||||
}
|
||||
|
||||
int calculateDisplacement(float starting_velocity, int *acceleration, unsigned int physics_time)
|
||||
{
|
||||
// cppcheck-suppress nullPointer
|
||||
// cppcheck-suppress ctunullpointer
|
||||
return starting_velocity * physics_time + ((1 / 2) * (*acceleration) * (physics_time ^ 2));
|
||||
}
|
||||
|
||||
void printXPosition(int position)
|
||||
{
|
||||
printf("\nx position is: %d\n", position);
|
||||
return;
|
||||
}
|
||||
|
||||
void printClock(unsigned int *time)
|
||||
{
|
||||
printf("%u seconds passed\n", *time);
|
||||
return;
|
||||
}
|
||||
|
||||
float calculateStopTime(float velocity) { return 1 / velocity; }
|
||||
|
||||
void printLine(int position)
|
||||
{
|
||||
clearScreen();
|
||||
for (int i = -(LINE_LENGTH / 2); i < LINE_LENGTH / 2; i++)
|
||||
{
|
||||
if (i == position)
|
||||
printf("x");
|
||||
else
|
||||
printf("-");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void printVelocity(float velocity)
|
||||
{
|
||||
printf("Velocity is: %f\n", velocity);
|
||||
return;
|
||||
}
|
||||
|
||||
int calculateTimePassed(float velocity)
|
||||
{
|
||||
if (velocity >= 1 || velocity <= -1)
|
||||
return 1;
|
||||
else
|
||||
{
|
||||
printf("Time passed is: %f\n", fabs(1 / velocity));
|
||||
return fabs(1 / velocity);
|
||||
}
|
||||
}
|
||||
|
||||
void printAllInfo(int position, unsigned int *time, float *velocity)
|
||||
{
|
||||
pauseForGivenTime(calculateStopTime(*velocity));
|
||||
printLine(position);
|
||||
printXPosition(position);
|
||||
*time += calculateTimePassed(*velocity);
|
||||
printClock(time);
|
||||
printVelocity(*velocity);
|
||||
// pauseForASecond();
|
||||
return;
|
||||
}
|
||||
|
||||
float chooseVelocity()
|
||||
{
|
||||
float velocity;
|
||||
printf("Write velocity of the object in m / s: ");
|
||||
scanf("%f", &velocity);
|
||||
return velocity;
|
||||
}
|
||||
|
||||
int chooseAcceleration()
|
||||
{
|
||||
int acceleration;
|
||||
printf("Choose acceleration of the object in m / (s ^ 2):");
|
||||
scanf("%d", &acceleration);
|
||||
return acceleration;
|
||||
}
|
||||
|
||||
int outOfLine(int position)
|
||||
{
|
||||
if ((position < LINE_LENGTH / 2) && (position > -1 * (LINE_LENGTH / 2)))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
return 1;
|
||||
}
|
||||
|
||||
void moveUntillOutOfLine(int position, unsigned int *time)
|
||||
{
|
||||
while (!outOfLine(position))
|
||||
{
|
||||
float velocity = chooseVelocity();
|
||||
float *Pvelocity = &velocity;
|
||||
position += calculateDisplacement(velocity, 0, 1);
|
||||
printAllInfo(position, time, Pvelocity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void moveUntillOutOfVelocity(int position, int *acceleration, unsigned int *time)
|
||||
{
|
||||
float velocity = 0;
|
||||
float *Pvelocity = &velocity;
|
||||
while (!outOfLine(position))
|
||||
{
|
||||
position += calculateDisplacement(velocity, acceleration, 1);
|
||||
printXPosition(position);
|
||||
pauseSystem();
|
||||
velocity = calculateVelocity(velocity, 1, acceleration);
|
||||
printAllInfo(position, time, Pvelocity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
55
C/1dvelocitysimulator/physics.h
Normal file
55
C/1dvelocitysimulator/physics.h
Normal file
@ -0,0 +1,55 @@
|
||||
#ifndef PHYSICS_H
|
||||
#define PHYSICS_H
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#define SLEEP_MS(ms) Sleep(ms)
|
||||
#define CLEAR_SCREEN() system("CLS")
|
||||
#define PAUSE() \
|
||||
do \
|
||||
{ \
|
||||
printf("Press Enter to continue..."); \
|
||||
getchar(); \
|
||||
} while (0)
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#ifdef TESTING
|
||||
#define SLEEP_MS(ms) ((void)0)
|
||||
#define CLEAR_SCREEN() ((void)0)
|
||||
#define PAUSE() ((void)0)
|
||||
#else
|
||||
#define SLEEP_MS(ms) usleep((ms) * 1000U)
|
||||
#define CLEAR_SCREEN() system("clear")
|
||||
#define PAUSE() \
|
||||
do \
|
||||
{ \
|
||||
printf("Press Enter to continue..."); \
|
||||
getchar(); \
|
||||
} while (0)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#define LINE_LENGTH 100
|
||||
|
||||
void C(void);
|
||||
void printAcceleration(int acceleration);
|
||||
void pauseSystem(void);
|
||||
void clearScreen(void);
|
||||
void pauseForASecond(void);
|
||||
void pauseForGivenTime(float given_time);
|
||||
float calculateVelocity(float starting_velocity, unsigned int physics_time, int *acceleration);
|
||||
int calculateDisplacement(float starting_velocity, int *acceleration, unsigned int physics_time);
|
||||
void printXPosition(int position);
|
||||
void printClock(unsigned int *time);
|
||||
float calculateStopTime(float velocity);
|
||||
void printLine(int position);
|
||||
void printVelocity(float velocity);
|
||||
int calculateTimePassed(float velocity);
|
||||
void printAllInfo(int position, unsigned int *time, float *velocity);
|
||||
float chooseVelocity(void);
|
||||
int chooseAcceleration(void);
|
||||
int outOfLine(int position);
|
||||
void moveUntillOutOfLine(int position, unsigned int *time);
|
||||
void moveUntillOutOfVelocity(int position, int *acceleration, unsigned int *time);
|
||||
|
||||
#endif /* PHYSICS_H */
|
||||
468
C/1dvelocitysimulator/test_physics.c
Normal file
468
C/1dvelocitysimulator/test_physics.c
Normal file
@ -0,0 +1,468 @@
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "physics.h"
|
||||
|
||||
static void test_calculateVelocity(void)
|
||||
{
|
||||
int accel = 2;
|
||||
float v = calculateVelocity(5.0f, 3, &accel);
|
||||
assert(fabsf(v - 11.0f) < 0.001f);
|
||||
|
||||
/* acceleration=0: velocity unchanged */
|
||||
int accel2 = 0;
|
||||
float v2 = calculateVelocity(10.0f, 5, &accel2);
|
||||
assert(fabsf(v2 - 10.0f) < 0.001f);
|
||||
|
||||
int accel3 = 0;
|
||||
float v3 = calculateVelocity(3.0f, 10, &accel3);
|
||||
assert(fabsf(v3 - 3.0f) < 0.001f);
|
||||
|
||||
/* time=0: velocity equals starting velocity regardless of accel */
|
||||
int accel4 = 100;
|
||||
float v4 = calculateVelocity(7.0f, 0, &accel4);
|
||||
assert(fabsf(v4 - 7.0f) < 0.001f);
|
||||
}
|
||||
|
||||
static void test_calculateDisplacement(void)
|
||||
{
|
||||
int accel = 2;
|
||||
int d = calculateDisplacement(5.0f, &accel, 3);
|
||||
/* With integer division (1/2)==0, result is starting_velocity * time + 0 */
|
||||
assert(d == 15);
|
||||
|
||||
int accel2 = 0;
|
||||
int d2 = calculateDisplacement(0.0f, &accel2, 10);
|
||||
assert(d2 == 0);
|
||||
}
|
||||
|
||||
static void test_calculateStopTime(void)
|
||||
{
|
||||
float t = calculateStopTime(2.0f);
|
||||
assert(fabsf(t - 0.5f) < 0.001f);
|
||||
|
||||
float t2 = calculateStopTime(0.5f);
|
||||
assert(fabsf(t2 - 2.0f) < 0.001f);
|
||||
}
|
||||
|
||||
static void test_calculateTimePassed_fast(void)
|
||||
{
|
||||
int t = calculateTimePassed(2.0f);
|
||||
assert(t == 1);
|
||||
|
||||
int t2 = calculateTimePassed(-5.0f);
|
||||
assert(t2 == 1);
|
||||
|
||||
int t3 = calculateTimePassed(1.0f);
|
||||
assert(t3 == 1);
|
||||
|
||||
int t4 = calculateTimePassed(-1.0f);
|
||||
assert(t4 == 1);
|
||||
}
|
||||
|
||||
static void test_calculateTimePassed_slow(void)
|
||||
{
|
||||
/* velocity between -1 and 1 (exclusive) takes the else branch */
|
||||
int t = calculateTimePassed(0.5f);
|
||||
assert(t == (int)fabsf(1.0f / 0.5f));
|
||||
|
||||
int t2 = calculateTimePassed(0.25f);
|
||||
assert(t2 == (int)fabsf(1.0f / 0.25f));
|
||||
|
||||
int t3 = calculateTimePassed(-0.5f);
|
||||
assert(t3 == (int)fabsf(1.0f / -0.5f));
|
||||
}
|
||||
|
||||
static void test_outOfLine(void)
|
||||
{
|
||||
assert(outOfLine(0) == 0);
|
||||
assert(outOfLine(10) == 0);
|
||||
assert(outOfLine(-10) == 0);
|
||||
assert(outOfLine(49) == 0);
|
||||
assert(outOfLine(-49) == 0);
|
||||
|
||||
/* at boundary and beyond */
|
||||
assert(outOfLine(50) == 1);
|
||||
assert(outOfLine(-50) == 1);
|
||||
assert(outOfLine(100) == 1);
|
||||
assert(outOfLine(-100) == 1);
|
||||
}
|
||||
|
||||
static void test_C_function(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
C();
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_printAcceleration(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
printAcceleration(5);
|
||||
printAcceleration(-3);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_pauseSystem(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
pauseSystem();
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_clearScreen(void) { clearScreen(); }
|
||||
|
||||
static void test_pauseForASecond(void) { pauseForASecond(); }
|
||||
|
||||
static void test_pauseForGivenTime(void)
|
||||
{
|
||||
pauseForGivenTime(0.5f);
|
||||
pauseForGivenTime(-0.5f);
|
||||
pauseForGivenTime(0.0f);
|
||||
}
|
||||
|
||||
static void test_printXPosition(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
printXPosition(0);
|
||||
printXPosition(42);
|
||||
printXPosition(-10);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_printClock(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
unsigned int t = 10;
|
||||
printClock(&t);
|
||||
t = 0;
|
||||
printClock(&t);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_printVelocity(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
printVelocity(3.14f);
|
||||
printVelocity(-1.0f);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_printLine(void)
|
||||
{
|
||||
/* Capture output to a temp file to verify content */
|
||||
FILE *tmp = tmpfile();
|
||||
assert(tmp != NULL);
|
||||
int fd = fileno(tmp);
|
||||
FILE *cap = fdopen(fd, "w+");
|
||||
/* Redirect stdout to tmp */
|
||||
FILE *saved = stdout;
|
||||
stdout = cap;
|
||||
|
||||
printLine(0);
|
||||
fflush(stdout);
|
||||
|
||||
stdout = saved;
|
||||
|
||||
/* Read back and verify "x" at the correct position */
|
||||
fseek(cap, 0, SEEK_END);
|
||||
long len = ftell(cap);
|
||||
fseek(cap, 0, SEEK_SET);
|
||||
char *buf = malloc(len + 1);
|
||||
assert(buf != NULL);
|
||||
fread(buf, 1, len, cap);
|
||||
buf[len] = '\0';
|
||||
|
||||
/* The output is LINE_LENGTH characters. Position 0 maps to index 50 */
|
||||
assert(len == LINE_LENGTH);
|
||||
assert(buf[50] == 'x');
|
||||
for (int i = 0; i < LINE_LENGTH; i++)
|
||||
{
|
||||
if (i != 50)
|
||||
assert(buf[i] == '-');
|
||||
}
|
||||
free(buf);
|
||||
fclose(cap);
|
||||
}
|
||||
|
||||
static void test_printLine_edge(void)
|
||||
{
|
||||
FILE *saved = stdout;
|
||||
FILE *tmp = tmpfile();
|
||||
assert(tmp != NULL);
|
||||
stdout = tmp;
|
||||
printLine(-50);
|
||||
fflush(stdout);
|
||||
stdout = saved;
|
||||
|
||||
fseek(tmp, 0, SEEK_END);
|
||||
long len = ftell(tmp);
|
||||
fseek(tmp, 0, SEEK_SET);
|
||||
char *buf = malloc(len + 1);
|
||||
assert(buf != NULL);
|
||||
fread(buf, 1, len, tmp);
|
||||
buf[len] = '\0';
|
||||
|
||||
/* position -50 maps to index 0 */
|
||||
assert(buf[0] == 'x');
|
||||
free(buf);
|
||||
fclose(tmp);
|
||||
}
|
||||
|
||||
static void test_printAllInfo(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
unsigned int t = 0;
|
||||
float vel = 2.0f;
|
||||
printAllInfo(0, &t, &vel);
|
||||
assert(t > 0);
|
||||
|
||||
/* slow velocity branch */
|
||||
float vel2 = 0.5f;
|
||||
unsigned int t2 = 0;
|
||||
printAllInfo(10, &t2, &vel2);
|
||||
assert(t2 > 0);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_chooseVelocity(void)
|
||||
{
|
||||
/* Redirect stdin to provide input */
|
||||
FILE *tmp_in = tmpfile();
|
||||
assert(tmp_in != NULL);
|
||||
fprintf(tmp_in, "3.5\n");
|
||||
fseek(tmp_in, 0, SEEK_SET);
|
||||
|
||||
FILE *saved_in = stdin;
|
||||
stdin = tmp_in;
|
||||
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
|
||||
float v = chooseVelocity();
|
||||
assert(fabsf(v - 3.5f) < 0.001f);
|
||||
|
||||
stdin = saved_in;
|
||||
fclose(tmp_in);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_chooseAcceleration(void)
|
||||
{
|
||||
FILE *tmp_in = tmpfile();
|
||||
assert(tmp_in != NULL);
|
||||
fprintf(tmp_in, "7\n");
|
||||
fseek(tmp_in, 0, SEEK_SET);
|
||||
|
||||
FILE *saved_in = stdin;
|
||||
stdin = tmp_in;
|
||||
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
|
||||
int a = chooseAcceleration();
|
||||
assert(a == 7);
|
||||
|
||||
stdin = saved_in;
|
||||
fclose(tmp_in);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_moveUntillOutOfLine_already_out(void)
|
||||
{
|
||||
/* Position already out of line: while loop body never executes */
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
unsigned int t = 0;
|
||||
moveUntillOutOfLine(999, &t);
|
||||
assert(t == 0);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_moveUntillOutOfLine_exits(void)
|
||||
{
|
||||
/*
|
||||
* Position starts in-line (0). Feed a large velocity via stdin so
|
||||
* calculateDisplacement moves position out of line in one step.
|
||||
* chooseVelocity reads a float; we feed "100\n".
|
||||
*/
|
||||
FILE *tmp_in = tmpfile();
|
||||
assert(tmp_in != NULL);
|
||||
fprintf(tmp_in, "100\n");
|
||||
fseek(tmp_in, 0, SEEK_SET);
|
||||
|
||||
FILE *saved_in = stdin;
|
||||
stdin = tmp_in;
|
||||
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
|
||||
unsigned int t = 0;
|
||||
moveUntillOutOfLine(0, &t);
|
||||
|
||||
stdin = saved_in;
|
||||
fclose(tmp_in);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_moveUntillOutOfVelocity_already_out(void)
|
||||
{
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
int accel = 5;
|
||||
unsigned int t = 0;
|
||||
moveUntillOutOfVelocity(999, &accel, &t);
|
||||
assert(t == 0);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
static void test_moveUntillOutOfVelocity_runs(void)
|
||||
{
|
||||
/*
|
||||
* Start at position 49 (near boundary) with positive acceleration.
|
||||
* velocity starts at 0, first iteration: displacement = 0*1 + 0 = 0, position
|
||||
* stays 49. velocity becomes accel*1+0 = 10. Second iteration: displacement =
|
||||
* 10*1 + 0 = 10, position = 59 -> out of line. We need at most a few iterations.
|
||||
* Since chooseVelocity/chooseAcceleration are NOT called in this function, no
|
||||
* stdin redirect needed.
|
||||
*/
|
||||
{
|
||||
FILE *_redir = freopen("/dev/null", "w", stdout);
|
||||
assert(_redir != NULL);
|
||||
(void)_redir;
|
||||
}
|
||||
int accel = 10;
|
||||
unsigned int t = 0;
|
||||
moveUntillOutOfVelocity(49, &accel, &t);
|
||||
assert(t > 0);
|
||||
{
|
||||
FILE *_restore = freopen("/dev/tty", "w", stdout);
|
||||
assert(_restore != NULL);
|
||||
(void)_restore;
|
||||
}
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_calculateVelocity();
|
||||
test_calculateDisplacement();
|
||||
test_calculateStopTime();
|
||||
test_calculateTimePassed_fast();
|
||||
test_calculateTimePassed_slow();
|
||||
test_outOfLine();
|
||||
test_C_function();
|
||||
test_printAcceleration();
|
||||
test_pauseSystem();
|
||||
test_clearScreen();
|
||||
test_pauseForASecond();
|
||||
test_pauseForGivenTime();
|
||||
test_printXPosition();
|
||||
test_printClock();
|
||||
test_printVelocity();
|
||||
test_printLine();
|
||||
test_printLine_edge();
|
||||
test_printAllInfo();
|
||||
test_chooseVelocity();
|
||||
test_chooseAcceleration();
|
||||
test_moveUntillOutOfLine_already_out();
|
||||
test_moveUntillOutOfLine_exits();
|
||||
test_moveUntillOutOfVelocity_already_out();
|
||||
test_moveUntillOutOfVelocity_runs();
|
||||
|
||||
printf("All tests passed!\n");
|
||||
return 0;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
CC := gcc
|
||||
CFLAGS := -O2 -std=c11 -Wall -Wextra -Wno-unused-parameter
|
||||
COV := -O0 -g --coverage -std=c11 -Wall -Wextra -Wno-unused-parameter -Wno-return-type
|
||||
LDFLAGS :=
|
||||
|
||||
SRC := main.c movegen.c search.c
|
||||
@ -9,7 +10,7 @@ BIN := random_engine
|
||||
PERFT_SRC := perft.c movegen.c
|
||||
PERFT_BIN := perft
|
||||
|
||||
.PHONY: all clean rebuild
|
||||
.PHONY: all clean rebuild test coverage
|
||||
|
||||
all: $(BIN)
|
||||
|
||||
@ -19,8 +20,44 @@ $(BIN): $(SRC)
|
||||
$(PERFT_BIN): $(PERFT_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
# ---- tests ------------------------------------------------------------------
|
||||
|
||||
test_movegen: test_movegen.c movegen.c movegen.h
|
||||
$(CC) $(COV) -o test_movegen test_movegen.c movegen.c
|
||||
|
||||
test_search: test_search.c search.c movegen.c movegen.h search.h
|
||||
$(CC) $(COV) -o test_search test_search.c search.c movegen.c
|
||||
|
||||
test: test_movegen test_search
|
||||
./test_movegen
|
||||
./test_search
|
||||
|
||||
# ---- coverage ---------------------------------------------------------------
|
||||
|
||||
coverage: test_movegen test_search
|
||||
./test_movegen
|
||||
./test_search
|
||||
lcov --capture --directory . --output-file coverage.info \
|
||||
--rc branch_coverage=1 --ignore-errors inconsistent,mismatch,unused
|
||||
lcov --extract coverage.info \
|
||||
"$(CURDIR)/movegen.c" "$(CURDIR)/search.c" \
|
||||
--output-file coverage.info \
|
||||
--ignore-errors unused,inconsistent
|
||||
@echo "--- Coverage Summary ---"
|
||||
lcov --summary coverage.info --rc branch_coverage=1 2>&1 | tee /tmp/lcov_engine_summary.txt
|
||||
@LINE_COV=$$(grep "lines" /tmp/lcov_engine_summary.txt | grep -oP '[0-9]+\.[0-9]+(?=%)'); \
|
||||
echo "Line coverage: $${LINE_COV}%"; \
|
||||
if [ "$$(echo "$${LINE_COV} < 100.0" | bc)" = "1" ]; then \
|
||||
echo "FAIL: line coverage below 100%"; exit 1; \
|
||||
fi
|
||||
@echo "OK: 100% line coverage achieved"
|
||||
|
||||
run: $(BIN)
|
||||
./$(BIN)
|
||||
|
||||
clean:
|
||||
rm -f $(BIN) $(PERFT_BIN)
|
||||
rm -f $(BIN) $(PERFT_BIN) test_movegen test_search \
|
||||
*.gcda *.gcno *.gcov coverage.info
|
||||
|
||||
rebuild: clean all
|
||||
|
||||
|
||||
@ -45,39 +45,6 @@ static Piece make_piece(char c)
|
||||
}
|
||||
}
|
||||
|
||||
static char piece_to_char(Piece p)
|
||||
{
|
||||
switch (p)
|
||||
{
|
||||
case WP:
|
||||
return 'P';
|
||||
case WN:
|
||||
return 'N';
|
||||
case WB:
|
||||
return 'B';
|
||||
case WR:
|
||||
return 'R';
|
||||
case WQ:
|
||||
return 'Q';
|
||||
case WK:
|
||||
return 'K';
|
||||
case BP:
|
||||
return 'p';
|
||||
case BN:
|
||||
return 'n';
|
||||
case BB:
|
||||
return 'b';
|
||||
case BR:
|
||||
return 'r';
|
||||
case BQ:
|
||||
return 'q';
|
||||
case BK:
|
||||
return 'k';
|
||||
default:
|
||||
return '.';
|
||||
}
|
||||
}
|
||||
|
||||
void set_startpos(Position *pos)
|
||||
{
|
||||
memset(pos, 0, sizeof(*pos));
|
||||
@ -707,8 +674,6 @@ static int gen_moves_internal(const Position *pos, Move *moves, int max_moves, i
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@ -5,13 +5,11 @@
|
||||
|
||||
static int piece_value(Piece p)
|
||||
{
|
||||
if (p == WK || p == BK)
|
||||
{
|
||||
return 0; // king is invaluable; PST handled later if needed
|
||||
}
|
||||
|
||||
switch (p)
|
||||
{
|
||||
case WK:
|
||||
case BK:
|
||||
return 0; /* king is invaluable; PST handled later if needed */
|
||||
case WP:
|
||||
case BP:
|
||||
return 100;
|
||||
@ -43,11 +41,7 @@ int evaluate(const Position *pos)
|
||||
continue;
|
||||
}
|
||||
Piece p = pos->board[sq];
|
||||
if (p == EMPTY)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int v = piece_value(p);
|
||||
int v = piece_value(p);
|
||||
if (p >= WP && p <= WK)
|
||||
{
|
||||
score += v;
|
||||
|
||||
1440
C/lichess_random_engine/test_movegen.c
Normal file
1440
C/lichess_random_engine/test_movegen.c
Normal file
File diff suppressed because it is too large
Load Diff
190
C/lichess_random_engine/test_search.c
Normal file
190
C/lichess_random_engine/test_search.c
Normal file
@ -0,0 +1,190 @@
|
||||
/*
|
||||
* test_search.c - Unit tests for search.c (evaluate, alphabeta).
|
||||
*/
|
||||
|
||||
#include "movegen.h"
|
||||
#include "search.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* =========================================================================
|
||||
* evaluate tests
|
||||
* ========================================================================= */
|
||||
|
||||
static void test_evaluate_startpos_equal(void)
|
||||
{
|
||||
Position pos;
|
||||
set_startpos(&pos);
|
||||
/* Symmetric position: score from white perspective should be 0 */
|
||||
assert(evaluate(&pos) == 0);
|
||||
}
|
||||
|
||||
static void test_evaluate_white_extra_queen(void)
|
||||
{
|
||||
Position pos;
|
||||
/* White has an extra queen */
|
||||
parse_fen(&pos, "4k3/8/8/8/8/8/8/Q3K3 w - - 0 1");
|
||||
int score = evaluate(&pos);
|
||||
assert(score > 0); /* White favored */
|
||||
}
|
||||
|
||||
static void test_evaluate_black_extra_queen(void)
|
||||
{
|
||||
Position pos;
|
||||
/* Black has an extra queen */
|
||||
parse_fen(&pos, "4k1q1/8/8/8/8/8/8/4K3 w - - 0 1");
|
||||
int score = evaluate(&pos);
|
||||
assert(score < 0); /* Black favored from white's perspective */
|
||||
}
|
||||
|
||||
static void test_evaluate_symmetric_material(void)
|
||||
{
|
||||
Position pos;
|
||||
/* Equal material: rook vs rook, same side */
|
||||
parse_fen(&pos, "4k2r/8/8/8/8/8/8/4K2R w - - 0 1");
|
||||
assert(evaluate(&pos) == 0);
|
||||
}
|
||||
|
||||
static void test_evaluate_pawn_advantage(void)
|
||||
{
|
||||
Position pos;
|
||||
/* White has 2 extra pawns */
|
||||
parse_fen(&pos, "4k3/8/8/8/8/8/1PP5/4K3 w - - 0 1");
|
||||
int white_score = evaluate(&pos);
|
||||
pos.side = BLACK;
|
||||
int black_score = evaluate(&pos);
|
||||
assert(white_score > 0);
|
||||
assert(black_score < 0); /* same position but from black's perspective */
|
||||
}
|
||||
|
||||
static void test_evaluate_all_piece_types(void)
|
||||
{
|
||||
Position pos;
|
||||
/* White: K+Q+R+B+N vs Black: K only */
|
||||
parse_fen(&pos, "4k3/8/8/8/8/8/8/QRBN1K2 w - - 0 1");
|
||||
int score = evaluate(&pos);
|
||||
assert(score > 0);
|
||||
/* White material: Q=900 + R=500 + B=330 + N=320 = 2050 */
|
||||
assert(score == 900 + 500 + 330 + 320);
|
||||
}
|
||||
|
||||
static void test_evaluate_black_pieces(void)
|
||||
{
|
||||
Position pos;
|
||||
/* Black: K+Q+R+B+N vs White: K only */
|
||||
parse_fen(&pos, "4kqrb/4n3/8/8/8/8/8/4K3 w - - 0 1");
|
||||
int score = evaluate(&pos);
|
||||
/* From white perspective, should be heavily negative */
|
||||
assert(score < -1000);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
* alphabeta tests
|
||||
* ========================================================================= */
|
||||
|
||||
static void test_alphabeta_single_capture(void)
|
||||
{
|
||||
Position pos;
|
||||
/* White rook can capture black queen - best move immediately obvious at depth 1 */
|
||||
parse_fen(&pos, "4k3/8/8/3q4/3R4/8/8/4K3 w - - 0 1");
|
||||
PrincipalVariation pv = {.from = -1, .to = -1};
|
||||
int score = alphabeta(pos, 1, -30000, 30000, &pv);
|
||||
assert(score > 0); /* Should find winning position */
|
||||
/* Best move should be d4d5 (rook captures queen on d5) */
|
||||
assert(pv.from >= 0);
|
||||
assert(pv.to >= 0);
|
||||
}
|
||||
|
||||
static void test_alphabeta_checkmate_in_one(void)
|
||||
{
|
||||
Position pos;
|
||||
/* White queen delivers checkmate at f7 */
|
||||
parse_fen(&pos, "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 4 4");
|
||||
PrincipalVariation pv = {.from = -1, .to = -1};
|
||||
int score = alphabeta(pos, 2, -30000, 30000, &pv);
|
||||
/* Depth 2: should find the mating sequence */
|
||||
(void)score;
|
||||
assert(pv.from >= 0);
|
||||
}
|
||||
|
||||
static void test_alphabeta_stalemate_score(void)
|
||||
{
|
||||
Position pos;
|
||||
/* Black king stalemated: ensure score is 0 (stalemate = draw)
|
||||
* k on a8, white queen on c7, white king on c6 */
|
||||
parse_fen(&pos, "k7/2Q5/2K5/8/8/8/8/8 b - - 0 1");
|
||||
/* Black to move, stalemated */
|
||||
PrincipalVariation pv = {.from = -1, .to = -1};
|
||||
int score = alphabeta(pos, 1, -30000, 30000, &pv);
|
||||
assert(score == 0); /* stalemate */
|
||||
}
|
||||
|
||||
static void test_alphabeta_checkmate_score(void)
|
||||
{
|
||||
Position pos;
|
||||
/* Black king checkmated (fool's mate) */
|
||||
parse_fen(&pos, "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3");
|
||||
/* White is mated; at depth 0 just evaluates material */
|
||||
PrincipalVariation pv = {.from = -1, .to = -1};
|
||||
int score = alphabeta(pos, 1, -30000, 30000, &pv);
|
||||
/* White has no legal moves - should return very negative score */
|
||||
assert(score < -20000);
|
||||
}
|
||||
|
||||
static void test_alphabeta_depth_zero(void)
|
||||
{
|
||||
Position pos;
|
||||
set_startpos(&pos);
|
||||
PrincipalVariation pv = {.from = -1, .to = -1};
|
||||
int score = alphabeta(pos, 0, -30000, 30000, &pv);
|
||||
/* At depth 0, returns leaf evaluation */
|
||||
assert(score == 0); /* startpos is equal */
|
||||
}
|
||||
|
||||
static void test_alphabeta_no_pv_null(void)
|
||||
{
|
||||
Position pos;
|
||||
set_startpos(&pos);
|
||||
/* Pass NULL for pv - should not crash */
|
||||
int score = alphabeta(pos, 1, -30000, 30000, NULL);
|
||||
(void)score;
|
||||
/* Just checking no crash */
|
||||
assert(1);
|
||||
}
|
||||
|
||||
static void test_alphabeta_beta_cutoff(void)
|
||||
{
|
||||
Position pos;
|
||||
/* Need a position where beta cutoff fires: search at depth 2+ */
|
||||
set_startpos(&pos);
|
||||
PrincipalVariation pv = {.from = -1, .to = -1};
|
||||
int score = alphabeta(pos, 2, -30000, 30000, &pv);
|
||||
/* Symmetric start - should be near 0 */
|
||||
(void)score;
|
||||
assert(pv.from >= 0);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* evaluate */
|
||||
test_evaluate_startpos_equal();
|
||||
test_evaluate_white_extra_queen();
|
||||
test_evaluate_black_extra_queen();
|
||||
test_evaluate_symmetric_material();
|
||||
test_evaluate_pawn_advantage();
|
||||
test_evaluate_all_piece_types();
|
||||
test_evaluate_black_pieces();
|
||||
|
||||
/* alphabeta */
|
||||
test_alphabeta_single_capture();
|
||||
test_alphabeta_checkmate_in_one();
|
||||
test_alphabeta_stalemate_score();
|
||||
test_alphabeta_checkmate_score();
|
||||
test_alphabeta_depth_zero();
|
||||
test_alphabeta_no_pv_null();
|
||||
test_alphabeta_beta_cutoff();
|
||||
|
||||
printf("All tests passed (%d tests).\n", 14);
|
||||
return 0;
|
||||
}
|
||||
6
C/misc/split/.gitignore
vendored
6
C/misc/split/.gitignore
vendored
@ -1 +1,7 @@
|
||||
split
|
||||
test_split
|
||||
*.gcda
|
||||
*.gcno
|
||||
*.gcov
|
||||
coverage.info
|
||||
coverage_html/
|
||||
|
||||
@ -2,9 +2,15 @@ CC := gcc
|
||||
CFLAGS := -O2 -Wall -Wextra -std=c11
|
||||
LDFLAGS :=
|
||||
|
||||
SRC := main.c
|
||||
SRC := main.c split.c
|
||||
BIN := split
|
||||
|
||||
TEST_SRC := test_split.c split.c
|
||||
TEST_BIN := test_split
|
||||
|
||||
COV_CFLAGS := -Wall -Wextra -std=c11 --coverage -g -O0
|
||||
COV_LDFLAGS := -lm
|
||||
|
||||
all: $(BIN)
|
||||
|
||||
$(BIN): $(SRC)
|
||||
@ -13,7 +19,29 @@ $(BIN): $(SRC)
|
||||
run: $(BIN)
|
||||
./$(BIN)
|
||||
|
||||
clean:
|
||||
rm -f $(BIN)
|
||||
test: $(TEST_BIN)
|
||||
./$(TEST_BIN)
|
||||
|
||||
.PHONY: all run clean
|
||||
$(TEST_BIN): $(TEST_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lm
|
||||
|
||||
coverage:
|
||||
$(CC) $(COV_CFLAGS) -o $(TEST_BIN) $(TEST_SRC) $(COV_LDFLAGS)
|
||||
./$(TEST_BIN)
|
||||
lcov --capture --directory . --output-file coverage.info --rc branch_coverage=1
|
||||
lcov --remove coverage.info '/usr/*' --output-file coverage.info \
|
||||
--rc branch_coverage=1 --ignore-errors unused
|
||||
@LINE_PCT=$$(lcov --summary coverage.info 2>&1 | grep -oP 'lines\.*:\s*\K[0-9.]+'); \
|
||||
echo "Line coverage: $${LINE_PCT}%"; \
|
||||
if [ "$$(echo "$${LINE_PCT} < 100.0" | bc -l)" = "1" ]; then \
|
||||
echo "FAIL: Line coverage $${LINE_PCT}% is below 100%"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "OK: 100% line coverage achieved"; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
rm -f $(BIN) $(TEST_BIN) *.gcda *.gcno *.gcov coverage.info
|
||||
rm -rf coverage_html
|
||||
|
||||
.PHONY: all run test coverage clean
|
||||
|
||||
@ -1,86 +1,6 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
// Function to calculate symmetric weights for both even and odd N
|
||||
void calculate_symmetric_weights(int N, double middle_weight, const double *factors,
|
||||
double *weights)
|
||||
{
|
||||
int half_N = N / 2;
|
||||
int i = 0;
|
||||
weights[half_N] = middle_weight; // Middle value for symmetry
|
||||
|
||||
// Calculate left side weights
|
||||
if (factors)
|
||||
{
|
||||
for (i = 0; i < half_N; i++)
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
weights[half_N - i - 1] = middle_weight + factors[i];
|
||||
}
|
||||
else
|
||||
{
|
||||
weights[half_N - i - 1] = weights[half_N - i] + factors[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (i = 0; i < half_N; i++)
|
||||
{
|
||||
weights[half_N - i - 1] = middle_weight - (i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror left side weights to right side
|
||||
for (i = 0; i < half_N; i++)
|
||||
{
|
||||
weights[half_N + i + 1] = weights[half_N - i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to scale the weights so that their sum is proportional to X
|
||||
void scale_to_total(double X, const double *weights, int N, double *distances)
|
||||
{
|
||||
double total_weight = 0;
|
||||
int i = 0;
|
||||
|
||||
// Calculate the total weight
|
||||
for (i = 0; i < N; i++)
|
||||
{
|
||||
total_weight += weights[i];
|
||||
}
|
||||
|
||||
double base_unit = X / total_weight;
|
||||
|
||||
// Scale weights
|
||||
for (i = 0; i < N; i++)
|
||||
{
|
||||
distances[i] = base_unit * weights[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to split X into N parts symmetrically
|
||||
void split_x_into_n_symmetrically(double X, int N, double *factors, double *distances)
|
||||
{
|
||||
double *weights = (double *)malloc(N * sizeof(double));
|
||||
|
||||
calculate_symmetric_weights(N, 1.0, factors, weights);
|
||||
scale_to_total(X, weights, N, distances);
|
||||
|
||||
free(weights);
|
||||
}
|
||||
|
||||
// Function to split X into N parts, with a specific middle value
|
||||
void split_x_into_n_middle(double X, int N, double middle_value, double *distances)
|
||||
{
|
||||
double *weights = (double *)malloc(N * sizeof(double));
|
||||
|
||||
calculate_symmetric_weights(N, middle_value, NULL, weights);
|
||||
scale_to_total(X, weights, N, distances);
|
||||
|
||||
free(weights);
|
||||
}
|
||||
#include "split.h"
|
||||
|
||||
// Example usage
|
||||
int main(void)
|
||||
|
||||
80
C/misc/split/split.c
Normal file
80
C/misc/split/split.c
Normal file
@ -0,0 +1,80 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "split.h"
|
||||
|
||||
void calculate_symmetric_weights(int N, double middle_weight, const double *factors,
|
||||
double *weights)
|
||||
{
|
||||
int half_N = N / 2;
|
||||
int i = 0;
|
||||
weights[half_N] = middle_weight;
|
||||
|
||||
if (factors)
|
||||
{
|
||||
for (i = 0; i < half_N; i++)
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
weights[half_N - i - 1] = middle_weight + factors[i];
|
||||
}
|
||||
else
|
||||
{
|
||||
weights[half_N - i - 1] = weights[half_N - i] + factors[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (i = 0; i < half_N; i++)
|
||||
{
|
||||
weights[half_N - i - 1] = middle_weight - (i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < half_N; i++)
|
||||
{
|
||||
weights[half_N + i + 1] = weights[half_N - i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
void scale_to_total(double X, const double *weights, int N, double *distances)
|
||||
{
|
||||
double total_weight = 0;
|
||||
int i = 0;
|
||||
|
||||
for (i = 0; i < N; i++)
|
||||
{
|
||||
total_weight += weights[i];
|
||||
}
|
||||
|
||||
double base_unit = X / total_weight;
|
||||
|
||||
for (i = 0; i < N; i++)
|
||||
{
|
||||
distances[i] = base_unit * weights[i];
|
||||
}
|
||||
}
|
||||
|
||||
void split_x_into_n_symmetrically(double X, int N, double *factors, double *distances)
|
||||
{
|
||||
double *weights = (double *)malloc((size_t)N * sizeof(double));
|
||||
if (!weights)
|
||||
return;
|
||||
|
||||
calculate_symmetric_weights(N, 1.0, factors, weights);
|
||||
scale_to_total(X, weights, N, distances);
|
||||
|
||||
free(weights);
|
||||
}
|
||||
|
||||
void split_x_into_n_middle(double X, int N, double middle_value, double *distances)
|
||||
{
|
||||
double *weights = (double *)malloc((size_t)N * sizeof(double));
|
||||
if (!weights)
|
||||
return;
|
||||
|
||||
calculate_symmetric_weights(N, middle_value, NULL, weights);
|
||||
scale_to_total(X, weights, N, distances);
|
||||
|
||||
free(weights);
|
||||
}
|
||||
13
C/misc/split/split.h
Normal file
13
C/misc/split/split.h
Normal file
@ -0,0 +1,13 @@
|
||||
#ifndef SPLIT_H
|
||||
#define SPLIT_H
|
||||
|
||||
void calculate_symmetric_weights(int N, double middle_weight, const double *factors,
|
||||
double *weights);
|
||||
|
||||
void scale_to_total(double X, const double *weights, int N, double *distances);
|
||||
|
||||
void split_x_into_n_symmetrically(double X, int N, double *factors, double *distances);
|
||||
|
||||
void split_x_into_n_middle(double X, int N, double middle_value, double *distances);
|
||||
|
||||
#endif
|
||||
214
C/misc/split/test_split.c
Normal file
214
C/misc/split/test_split.c
Normal file
@ -0,0 +1,214 @@
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "split.h"
|
||||
|
||||
#define EPSILON 1e-9
|
||||
|
||||
static void assert_close(double a, double b) { assert(fabs(a - b) < EPSILON); }
|
||||
|
||||
static double sum_array(const double *arr, int n)
|
||||
{
|
||||
double s = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
s += arr[i];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* calculate_symmetric_weights: with factors, odd N */
|
||||
static void test_symmetric_weights_with_factors_odd(void)
|
||||
{
|
||||
double weights[5];
|
||||
double factors[2] = {1.0, 2.0};
|
||||
|
||||
calculate_symmetric_weights(5, 1.0, factors, weights);
|
||||
|
||||
/* middle = 1.0 */
|
||||
assert_close(weights[2], 1.0);
|
||||
/* i=0: weights[1] = middle + factors[0] = 2.0 */
|
||||
assert_close(weights[1], 2.0);
|
||||
/* i=1: weights[0] = weights[1] + factors[1] = 4.0 */
|
||||
assert_close(weights[0], 4.0);
|
||||
/* mirror: weights[3] = weights[1] = 2.0, weights[4] = weights[0] = 4.0 */
|
||||
assert_close(weights[3], 2.0);
|
||||
assert_close(weights[4], 4.0);
|
||||
}
|
||||
|
||||
/* calculate_symmetric_weights: with factors, even N */
|
||||
static void test_symmetric_weights_with_factors_even(void)
|
||||
{
|
||||
double weights[4];
|
||||
double factors[2] = {0.5, 1.5};
|
||||
|
||||
calculate_symmetric_weights(4, 3.0, factors, weights);
|
||||
|
||||
/* half_N = 2, middle index = 2 */
|
||||
assert_close(weights[2], 3.0);
|
||||
/* i=0: weights[1] = 3.0 + 0.5 = 3.5 */
|
||||
assert_close(weights[1], 3.5);
|
||||
/* i=1: weights[0] = weights[1] + 1.5 = 5.0 */
|
||||
assert_close(weights[0], 5.0);
|
||||
/* mirror: weights[3] = weights[1] = 3.5 */
|
||||
assert_close(weights[3], 3.5);
|
||||
}
|
||||
|
||||
/* calculate_symmetric_weights: without factors (NULL), odd N */
|
||||
static void test_symmetric_weights_null_factors_odd(void)
|
||||
{
|
||||
double weights[5];
|
||||
|
||||
calculate_symmetric_weights(5, 5.0, NULL, weights);
|
||||
|
||||
/* middle = 5.0 */
|
||||
assert_close(weights[2], 5.0);
|
||||
/* i=0: weights[1] = 5.0 - 1 = 4.0 */
|
||||
assert_close(weights[1], 4.0);
|
||||
/* i=1: weights[0] = 5.0 - 2 = 3.0 */
|
||||
assert_close(weights[0], 3.0);
|
||||
/* mirror */
|
||||
assert_close(weights[3], 4.0);
|
||||
assert_close(weights[4], 3.0);
|
||||
}
|
||||
|
||||
/* calculate_symmetric_weights: without factors, even N */
|
||||
static void test_symmetric_weights_null_factors_even(void)
|
||||
{
|
||||
double weights[6];
|
||||
|
||||
calculate_symmetric_weights(6, 10.0, NULL, weights);
|
||||
|
||||
/* half_N = 3, middle index = 3 */
|
||||
assert_close(weights[3], 10.0);
|
||||
/* i=0: weights[2] = 10.0 - 1 = 9.0 */
|
||||
assert_close(weights[2], 9.0);
|
||||
/* i=1: weights[1] = 10.0 - 2 = 8.0 */
|
||||
assert_close(weights[1], 8.0);
|
||||
/* i=2: weights[0] = 10.0 - 3 = 7.0 */
|
||||
assert_close(weights[0], 7.0);
|
||||
/* mirror */
|
||||
assert_close(weights[4], 9.0);
|
||||
assert_close(weights[5], 8.0);
|
||||
}
|
||||
|
||||
/* calculate_symmetric_weights: N=1 (half_N=0, loops don't execute) */
|
||||
static void test_symmetric_weights_n1(void)
|
||||
{
|
||||
double weights[1];
|
||||
|
||||
calculate_symmetric_weights(1, 42.0, NULL, weights);
|
||||
assert_close(weights[0], 42.0);
|
||||
|
||||
double factors[1] = {99.0};
|
||||
calculate_symmetric_weights(1, 7.0, factors, weights);
|
||||
assert_close(weights[0], 7.0);
|
||||
}
|
||||
|
||||
/* scale_to_total: verify distances sum to X */
|
||||
static void test_scale_to_total(void)
|
||||
{
|
||||
double weights[3] = {1.0, 2.0, 1.0};
|
||||
double distances[3] = {0};
|
||||
|
||||
scale_to_total(100.0, weights, 3, distances);
|
||||
|
||||
assert_close(sum_array(distances, 3), 100.0);
|
||||
/* total_weight = 4, base_unit = 25 */
|
||||
assert_close(distances[0], 25.0);
|
||||
assert_close(distances[1], 50.0);
|
||||
assert_close(distances[2], 25.0);
|
||||
}
|
||||
|
||||
/* scale_to_total: single element */
|
||||
static void test_scale_to_total_single(void)
|
||||
{
|
||||
double weights[1] = {5.0};
|
||||
double distances[1] = {0};
|
||||
|
||||
scale_to_total(200.0, weights, 1, distances);
|
||||
assert_close(distances[0], 200.0);
|
||||
}
|
||||
|
||||
/* split_x_into_n_symmetrically: N=5 with factors */
|
||||
static void test_split_symmetrically(void)
|
||||
{
|
||||
double factors[2] = {1.0, 2.0};
|
||||
double distances[5] = {0};
|
||||
|
||||
split_x_into_n_symmetrically(100.0, 5, factors, distances);
|
||||
|
||||
/* weights: [4, 2, 1, 2, 4] => total=13, base_unit=100/13 */
|
||||
assert_close(sum_array(distances, 5), 100.0);
|
||||
/* symmetry */
|
||||
assert_close(distances[0], distances[4]);
|
||||
assert_close(distances[1], distances[3]);
|
||||
/* middle is smallest */
|
||||
assert(distances[2] < distances[1]);
|
||||
assert(distances[1] < distances[0]);
|
||||
}
|
||||
|
||||
/* split_x_into_n_symmetrically: N=3 with factors */
|
||||
static void test_split_symmetrically_n3(void)
|
||||
{
|
||||
double factors[1] = {2.0};
|
||||
double distances[3] = {0};
|
||||
|
||||
split_x_into_n_symmetrically(60.0, 3, factors, distances);
|
||||
|
||||
assert_close(sum_array(distances, 3), 60.0);
|
||||
assert_close(distances[0], distances[2]);
|
||||
}
|
||||
|
||||
/* split_x_into_n_middle: N=5 with middle value */
|
||||
static void test_split_middle(void)
|
||||
{
|
||||
double distances[5] = {0};
|
||||
|
||||
split_x_into_n_middle(100.0, 5, 5.0, distances);
|
||||
|
||||
assert_close(sum_array(distances, 5), 100.0);
|
||||
/* symmetry */
|
||||
assert_close(distances[0], distances[4]);
|
||||
assert_close(distances[1], distances[3]);
|
||||
}
|
||||
|
||||
/* split_x_into_n_middle: N=3 with middle value */
|
||||
static void test_split_middle_n3(void)
|
||||
{
|
||||
double distances[3] = {0};
|
||||
|
||||
split_x_into_n_middle(90.0, 3, 10.0, distances);
|
||||
|
||||
assert_close(sum_array(distances, 3), 90.0);
|
||||
assert_close(distances[0], distances[2]);
|
||||
}
|
||||
|
||||
/* split_x_into_n_middle: N=1 */
|
||||
static void test_split_middle_n1(void)
|
||||
{
|
||||
double distances[1] = {0};
|
||||
|
||||
split_x_into_n_middle(50.0, 1, 7.0, distances);
|
||||
assert_close(distances[0], 50.0);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_symmetric_weights_with_factors_odd();
|
||||
test_symmetric_weights_with_factors_even();
|
||||
test_symmetric_weights_null_factors_odd();
|
||||
test_symmetric_weights_null_factors_even();
|
||||
test_symmetric_weights_n1();
|
||||
test_scale_to_total();
|
||||
test_scale_to_total_single();
|
||||
test_split_symmetrically();
|
||||
test_split_symmetrically_n3();
|
||||
test_split_middle();
|
||||
test_split_middle_n3();
|
||||
test_split_middle_n1();
|
||||
|
||||
printf("All tests passed.\n");
|
||||
return 0;
|
||||
}
|
||||
@ -1,13 +1,43 @@
|
||||
CC = gcc
|
||||
CFLAGS = -O3 -Wall -Wextra -march=native
|
||||
TARGET = vocabulary_curve
|
||||
CC = gcc
|
||||
CFLAGS = -O3 -Wall -Wextra -march=native
|
||||
COV = -O0 -g --coverage -Wall -Wextra
|
||||
TARGET = vocabulary_curve
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): main.c
|
||||
$(CC) $(CFLAGS) -o $(TARGET) main.c
|
||||
$(TARGET): main.c vocabulary.c vocabulary.h
|
||||
$(CC) $(CFLAGS) -o $(TARGET) main.c vocabulary.c
|
||||
|
||||
# ---- tests ---------------------------------------------------------------
|
||||
|
||||
test_vocabulary: test_vocabulary.c vocabulary.c vocabulary.h
|
||||
$(CC) $(COV) -o test_vocabulary test_vocabulary.c vocabulary.c
|
||||
|
||||
test: test_vocabulary
|
||||
./test_vocabulary
|
||||
|
||||
# ---- coverage ------------------------------------------------------------
|
||||
|
||||
coverage: test_vocabulary
|
||||
./test_vocabulary
|
||||
lcov --capture --directory . --output-file coverage.info \
|
||||
--rc branch_coverage=1 --ignore-errors inconsistent,mismatch,unused
|
||||
lcov --extract coverage.info "$(CURDIR)/vocabulary.c" \
|
||||
--output-file coverage.info \
|
||||
--ignore-errors unused,inconsistent
|
||||
@echo "--- Coverage Summary ---"
|
||||
lcov --summary coverage.info --rc branch_coverage=1 2>&1 | tee /tmp/lcov_summary.txt
|
||||
@LINE_COV=$$(grep "lines" /tmp/lcov_summary.txt | grep -oP '[0-9]+\.[0-9]+(?=%)'); \
|
||||
echo "Line coverage: $${LINE_COV}%"; \
|
||||
if [ "$$(echo "$${LINE_COV} < 100.0" | bc)" = "1" ]; then \
|
||||
echo "FAIL: line coverage below 100%"; exit 1; \
|
||||
fi
|
||||
@echo "OK: 100% line coverage achieved"
|
||||
|
||||
run: $(TARGET)
|
||||
./$(TARGET)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
rm -f $(TARGET) test_vocabulary *.gcda *.gcno *.gcov coverage.info
|
||||
|
||||
.PHONY: all clean
|
||||
.PHONY: all run clean test coverage
|
||||
|
||||
@ -1,282 +1,36 @@
|
||||
/*
|
||||
* Vocabulary Learning Curve Analyzer
|
||||
*
|
||||
* For each excerpt length (1, 2, 3, ... N words), finds the excerpt that
|
||||
* requires the minimum number of top-frequency words to understand 100%.
|
||||
*
|
||||
* Usage:
|
||||
* ./vocabulary_curve <file.txt> [max_length]
|
||||
* ./vocabulary_curve test.txt 50
|
||||
* Vocabulary Learning Curve Analyzer - thin driver.
|
||||
*/
|
||||
|
||||
#include <ctype.h>
|
||||
#include "vocabulary.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define MAX_WORD_LEN 64
|
||||
#define MAX_WORDS 500000
|
||||
#define MAX_UNIQUE_WORDS 100000
|
||||
#define HASH_SIZE 200003 /* Prime number for better distribution */
|
||||
|
||||
/* Word entry for hash table */
|
||||
typedef struct WordEntry
|
||||
{
|
||||
char word[MAX_WORD_LEN];
|
||||
int count;
|
||||
int rank; /* 1-indexed rank by frequency (1 = most common) */
|
||||
struct WordEntry *next;
|
||||
} WordEntry;
|
||||
|
||||
/* Hash table for word lookup */
|
||||
static WordEntry *hash_table[HASH_SIZE];
|
||||
static WordEntry *all_entries[MAX_UNIQUE_WORDS];
|
||||
static int num_unique_words = 0;
|
||||
|
||||
/* All words in order of appearance - store POINTERS not indices */
|
||||
static WordEntry *word_sequence[MAX_WORDS];
|
||||
static int num_words = 0;
|
||||
|
||||
/* Result for each excerpt length */
|
||||
typedef struct
|
||||
{
|
||||
int excerpt_length;
|
||||
int min_vocab_needed;
|
||||
int start_pos; /* Start position in word_sequence */
|
||||
} ExcerptResult;
|
||||
|
||||
/* Simple hash function */
|
||||
static unsigned int hash_word(const char *word)
|
||||
{
|
||||
unsigned int hash = 5381;
|
||||
int c;
|
||||
while ((c = *word++))
|
||||
{
|
||||
hash = ((hash << 5) + hash) + c;
|
||||
}
|
||||
return hash % HASH_SIZE;
|
||||
}
|
||||
|
||||
/* Find or create word entry */
|
||||
static WordEntry *get_or_create_word(const char *word)
|
||||
{
|
||||
unsigned int h = hash_word(word);
|
||||
WordEntry *entry = hash_table[h];
|
||||
|
||||
while (entry)
|
||||
{
|
||||
if (strcmp(entry->word, word) == 0)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
entry = entry->next;
|
||||
}
|
||||
|
||||
/* Create new entry */
|
||||
if (num_unique_words >= MAX_UNIQUE_WORDS)
|
||||
{
|
||||
fprintf(stderr, "Too many unique words\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
entry = malloc(sizeof(WordEntry));
|
||||
if (!entry)
|
||||
{
|
||||
fprintf(stderr, "Memory allocation failed\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
strncpy(entry->word, word, MAX_WORD_LEN - 1);
|
||||
entry->word[MAX_WORD_LEN - 1] = '\0';
|
||||
entry->count = 0;
|
||||
entry->rank = 0;
|
||||
entry->next = hash_table[h];
|
||||
hash_table[h] = entry;
|
||||
|
||||
all_entries[num_unique_words++] = entry;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/* Compare function for sorting by frequency (descending) */
|
||||
static int compare_by_count(const void *a, const void *b)
|
||||
{
|
||||
const WordEntry *wa = *(const WordEntry **)a;
|
||||
const WordEntry *wb = *(const WordEntry **)b;
|
||||
return wb->count - wa->count; /* Descending */
|
||||
}
|
||||
|
||||
/* Check if character is part of a word */
|
||||
static bool is_word_char(int c) { return isalnum(c) || c == '_' || (unsigned char)c >= 128; }
|
||||
|
||||
/* Read and process file */
|
||||
static bool process_file(const char *filename)
|
||||
{
|
||||
FILE *fp = fopen(filename, "r");
|
||||
if (!fp)
|
||||
{
|
||||
fprintf(stderr, "Cannot open file: %s\n", filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
char word[MAX_WORD_LEN];
|
||||
int word_len = 0;
|
||||
int c;
|
||||
|
||||
while ((c = fgetc(fp)) != EOF)
|
||||
{
|
||||
if (is_word_char(c))
|
||||
{
|
||||
if (word_len < MAX_WORD_LEN - 1)
|
||||
{
|
||||
word[word_len++] = tolower(c);
|
||||
}
|
||||
}
|
||||
else if (word_len > 0)
|
||||
{
|
||||
word[word_len] = '\0';
|
||||
|
||||
WordEntry *entry = get_or_create_word(word);
|
||||
entry->count++;
|
||||
|
||||
if (num_words >= MAX_WORDS)
|
||||
{
|
||||
fprintf(stderr, "Too many words in file\n");
|
||||
fclose(fp);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Store pointer directly - survives sorting */
|
||||
word_sequence[num_words++] = entry;
|
||||
|
||||
word_len = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle last word if file doesn't end with whitespace */
|
||||
if (word_len > 0)
|
||||
{
|
||||
word[word_len] = '\0';
|
||||
WordEntry *entry = get_or_create_word(word);
|
||||
entry->count++;
|
||||
|
||||
if (num_words < MAX_WORDS)
|
||||
{
|
||||
word_sequence[num_words++] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Assign ranks based on frequency */
|
||||
static void assign_ranks(void)
|
||||
{
|
||||
/* Sort all_entries by frequency (this doesn't affect word_sequence) */
|
||||
qsort(all_entries, num_unique_words, sizeof(WordEntry *), compare_by_count);
|
||||
|
||||
/* Assign 1-indexed ranks using competition ranking:
|
||||
* Words with same frequency get same rank.
|
||||
* Next rank is current_position + 1 (skipping numbers).
|
||||
* Example: counts 5,3,3,2 -> ranks 1,2,2,4 (not 1,2,3,4) */
|
||||
for (int i = 0; i < num_unique_words; i++)
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
all_entries[i]->rank = 1;
|
||||
}
|
||||
else if (all_entries[i]->count == all_entries[i - 1]->count)
|
||||
{
|
||||
/* Same frequency as previous word - same rank */
|
||||
all_entries[i]->rank = all_entries[i - 1]->rank;
|
||||
}
|
||||
else
|
||||
{
|
||||
/* Different frequency - rank is position + 1 */
|
||||
all_entries[i]->rank = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Analyze excerpt and return max rank needed */
|
||||
static int analyze_excerpt(int start, int length)
|
||||
{
|
||||
/* Track which entries we've seen using a simple visited array */
|
||||
/* We use the rank field is already assigned, so we can check uniqueness */
|
||||
static bool seen_rank[MAX_UNIQUE_WORDS + 1];
|
||||
memset(seen_rank, 0, (num_unique_words + 1) * sizeof(bool));
|
||||
|
||||
int max_rank = 0;
|
||||
|
||||
for (int i = start; i < start + length; i++)
|
||||
{
|
||||
WordEntry *entry = word_sequence[i];
|
||||
int rank = entry->rank;
|
||||
|
||||
if (!seen_rank[rank])
|
||||
{
|
||||
seen_rank[rank] = true;
|
||||
if (rank > max_rank)
|
||||
{
|
||||
max_rank = rank;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return max_rank;
|
||||
}
|
||||
|
||||
/* Find optimal excerpts for each length */
|
||||
static void find_optimal_excerpts(int max_length, ExcerptResult *results)
|
||||
{
|
||||
for (int length = 1; length <= max_length && length <= num_words; length++)
|
||||
{
|
||||
int best_vocab = num_unique_words + 1;
|
||||
int best_start = 0;
|
||||
|
||||
/* Slide window through text */
|
||||
for (int start = 0; start <= num_words - length; start++)
|
||||
{
|
||||
int vocab_needed = analyze_excerpt(start, length);
|
||||
|
||||
if (vocab_needed < best_vocab)
|
||||
{
|
||||
best_vocab = vocab_needed;
|
||||
best_start = start;
|
||||
}
|
||||
}
|
||||
|
||||
results[length - 1].excerpt_length = length;
|
||||
results[length - 1].min_vocab_needed = best_vocab;
|
||||
results[length - 1].start_pos = best_start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print excerpt words */
|
||||
static void print_excerpt(int start, int length)
|
||||
static void print_excerpt(const VocabContext *ctx, int start, int length)
|
||||
{
|
||||
for (int i = start; i < start + length; i++)
|
||||
{
|
||||
if (i > start)
|
||||
printf(" ");
|
||||
printf("%s", word_sequence[i]->word);
|
||||
printf("%s", ctx->word_sequence[i]->word);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print words needed (sorted by rank) */
|
||||
static void print_words_needed(int start, int length)
|
||||
static void print_words_needed(const VocabContext *ctx, int start, int length)
|
||||
{
|
||||
/* Collect unique entries */
|
||||
static WordEntry *unique_entries[MAX_UNIQUE_WORDS];
|
||||
static bool seen_rank[MAX_UNIQUE_WORDS + 1];
|
||||
memset(seen_rank, 0, (num_unique_words + 1) * sizeof(bool));
|
||||
memset(seen_rank, 0, (ctx->num_unique_words + 1) * sizeof(bool));
|
||||
|
||||
int count = 0;
|
||||
for (int i = start; i < start + length; i++)
|
||||
{
|
||||
WordEntry *entry = word_sequence[i];
|
||||
WordEntry *entry = ctx->word_sequence[i];
|
||||
if (!seen_rank[entry->rank])
|
||||
{
|
||||
seen_rank[entry->rank] = true;
|
||||
@ -284,7 +38,6 @@ static void print_words_needed(int start, int length)
|
||||
}
|
||||
}
|
||||
|
||||
/* Sort by rank (simple bubble sort - small arrays) */
|
||||
for (int i = 0; i < count - 1; i++)
|
||||
{
|
||||
for (int j = i + 1; j < count; j++)
|
||||
@ -298,7 +51,6 @@ static void print_words_needed(int start, int length)
|
||||
}
|
||||
}
|
||||
|
||||
/* Print */
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
@ -308,44 +60,38 @@ static void print_words_needed(int start, int length)
|
||||
}
|
||||
|
||||
/* Print results */
|
||||
static void print_results(ExcerptResult *results, int max_length)
|
||||
static void print_results(const VocabContext *ctx, ExcerptResult *results, int max_length)
|
||||
{
|
||||
printf("======================================================================\n");
|
||||
printf("VOCABULARY LEARNING CURVE\n");
|
||||
printf("======================================================================\n");
|
||||
printf("\n");
|
||||
printf("For each excerpt length, the minimum number of top-frequency\n");
|
||||
printf("words you need to learn to understand 100%% of some excerpt.\n");
|
||||
printf("words you need to learn to understand 100%%%% of some excerpt.\n");
|
||||
printf("\n");
|
||||
printf("Total words in text: %d\n", num_words);
|
||||
printf("Unique words: %d\n", num_unique_words);
|
||||
printf("Total words in text: %d\n", ctx->num_words);
|
||||
printf("Unique words: %d\n", ctx->num_unique_words);
|
||||
printf("\n");
|
||||
printf("----------------------------------------------------------------------\n");
|
||||
|
||||
int prev_vocab = 0;
|
||||
int actual_max = max_length;
|
||||
if (actual_max > num_words)
|
||||
actual_max = num_words;
|
||||
if (actual_max > ctx->num_words)
|
||||
actual_max = ctx->num_words;
|
||||
|
||||
for (int i = 0; i < actual_max; i++)
|
||||
{
|
||||
ExcerptResult *r = &results[i];
|
||||
|
||||
printf("\n[Length %d] Vocab needed: %d", r->excerpt_length, r->min_vocab_needed);
|
||||
if (r->min_vocab_needed > prev_vocab)
|
||||
{
|
||||
printf(" (+%d)", r->min_vocab_needed - prev_vocab);
|
||||
}
|
||||
printf("\n");
|
||||
|
||||
printf(" Excerpt: \"");
|
||||
print_excerpt(r->start_pos, r->excerpt_length);
|
||||
print_excerpt(ctx, r->start_pos, r->excerpt_length);
|
||||
printf("\"\n");
|
||||
|
||||
printf(" Words: ");
|
||||
print_words_needed(r->start_pos, r->excerpt_length);
|
||||
print_words_needed(ctx, r->start_pos, r->excerpt_length);
|
||||
printf("\n");
|
||||
|
||||
prev_vocab = r->min_vocab_needed;
|
||||
}
|
||||
|
||||
@ -359,63 +105,26 @@ static void print_results(ExcerptResult *results, int max_length)
|
||||
}
|
||||
}
|
||||
|
||||
/* Free memory */
|
||||
static void cleanup(void)
|
||||
{
|
||||
for (int i = 0; i < num_unique_words; i++)
|
||||
{
|
||||
free(all_entries[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dump all vocabulary with ranks (for Python integration) */
|
||||
static void dump_vocabulary(int max_rank)
|
||||
static void dump_vocabulary(const VocabContext *ctx, int max_rank)
|
||||
{
|
||||
printf("VOCAB_DUMP_START\n");
|
||||
for (int i = 0; i < num_unique_words; i++)
|
||||
for (int i = 0; i < ctx->num_unique_words; i++)
|
||||
{
|
||||
if (all_entries[i]->rank <= max_rank)
|
||||
{
|
||||
printf("%s;%d\n", all_entries[i]->word, all_entries[i]->rank);
|
||||
}
|
||||
if (ctx->all_entries[i]->rank <= max_rank)
|
||||
printf("%s;%d\n", ctx->all_entries[i]->word, ctx->all_entries[i]->rank);
|
||||
}
|
||||
printf("VOCAB_DUMP_END\n");
|
||||
}
|
||||
|
||||
/* Find longest excerpt using only top N words (inverse mode) */
|
||||
static void find_longest_excerpt(int max_vocab)
|
||||
static void print_longest_excerpt_result(const VocabContext *ctx, int max_vocab, int best_start,
|
||||
int best_length)
|
||||
{
|
||||
/* Sliding window: find longest contiguous sequence where all words have rank <= max_vocab */
|
||||
int best_start = 0;
|
||||
int best_length = 0;
|
||||
|
||||
int left = 0;
|
||||
for (int right = 0; right < num_words; right++)
|
||||
{
|
||||
/* If current word is outside our vocabulary, move left past it */
|
||||
if (word_sequence[right]->rank > max_vocab)
|
||||
{
|
||||
left = right + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
/* Current window [left, right] is valid */
|
||||
int length = right - left + 1;
|
||||
if (length > best_length)
|
||||
{
|
||||
best_length = length;
|
||||
best_start = left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Print results */
|
||||
printf("======================================================================\n");
|
||||
printf("INVERSE MODE: LONGEST EXCERPT WITH TOP %d WORDS\n", max_vocab);
|
||||
printf("======================================================================\n");
|
||||
printf("\n");
|
||||
printf("Total words in text: %d\n", num_words);
|
||||
printf("Unique words: %d\n", num_unique_words);
|
||||
printf("Total words in text: %d\n", ctx->num_words);
|
||||
printf("Unique words: %d\n", ctx->num_unique_words);
|
||||
printf("Vocabulary limit: top %d words\n", max_vocab);
|
||||
printf("\n");
|
||||
printf("----------------------------------------------------------------------\n");
|
||||
@ -432,33 +141,31 @@ static void find_longest_excerpt(int max_vocab)
|
||||
printf("Position: words %d to %d\n", best_start + 1, best_start + best_length);
|
||||
printf("\n");
|
||||
printf("Excerpt:\n \"");
|
||||
print_excerpt(best_start, best_length);
|
||||
print_excerpt(ctx, best_start, best_length);
|
||||
printf("\"\n");
|
||||
printf("\n");
|
||||
|
||||
/* Find the rarest word in the excerpt */
|
||||
int max_rank_used = 0;
|
||||
const char *rarest_word = NULL;
|
||||
for (int i = best_start; i < best_start + best_length; i++)
|
||||
{
|
||||
if (word_sequence[i]->rank > max_rank_used)
|
||||
if (ctx->word_sequence[i]->rank > max_rank_used)
|
||||
{
|
||||
max_rank_used = word_sequence[i]->rank;
|
||||
rarest_word = word_sequence[i]->word;
|
||||
max_rank_used = ctx->word_sequence[i]->rank;
|
||||
rarest_word = ctx->word_sequence[i]->word;
|
||||
}
|
||||
}
|
||||
// cppcheck-suppress nullPointer
|
||||
printf("Rarest word used: %s (#%d)\n", rarest_word, max_rank_used);
|
||||
|
||||
/* Count unique words in excerpt */
|
||||
static bool seen_rank[MAX_UNIQUE_WORDS + 1];
|
||||
memset(seen_rank, 0, (num_unique_words + 1) * sizeof(bool));
|
||||
memset(seen_rank, 0, (ctx->num_unique_words + 1) * sizeof(bool));
|
||||
int unique_count = 0;
|
||||
for (int i = best_start; i < best_start + best_length; i++)
|
||||
{
|
||||
if (!seen_rank[word_sequence[i]->rank])
|
||||
if (!seen_rank[ctx->word_sequence[i]->rank])
|
||||
{
|
||||
seen_rank[word_sequence[i]->rank] = true;
|
||||
seen_rank[ctx->word_sequence[i]->rank] = true;
|
||||
unique_count++;
|
||||
}
|
||||
}
|
||||
@ -492,18 +199,15 @@ int main(int argc, char *argv[])
|
||||
int max_length = 30;
|
||||
bool dump_vocab = false;
|
||||
int dump_max_rank = 0;
|
||||
int max_vocab_mode = 0; /* 0 = normal mode, >0 = inverse mode with this vocab limit */
|
||||
int max_vocab_mode = 0;
|
||||
|
||||
/* Parse arguments */
|
||||
for (int i = 2; i < argc; i++)
|
||||
{
|
||||
if (strcmp(argv[i], "--dump-vocab") == 0)
|
||||
{
|
||||
dump_vocab = true;
|
||||
if (i + 1 < argc && argv[i + 1][0] != '-')
|
||||
{
|
||||
dump_max_rank = atoi(argv[++i]);
|
||||
}
|
||||
}
|
||||
else if (strcmp(argv[i], "--max-vocab") == 0)
|
||||
{
|
||||
@ -532,72 +236,73 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
}
|
||||
|
||||
/* Initialize hash table */
|
||||
memset(hash_table, 0, sizeof(hash_table));
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
|
||||
/* Process file */
|
||||
if (!process_file(filename))
|
||||
FILE *fp = fopen(filename, "r");
|
||||
if (!fp)
|
||||
{
|
||||
fprintf(stderr, "Cannot open file: %s\n", filename);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (num_words == 0)
|
||||
bool ok = vocab_process_stream(&ctx, fp);
|
||||
fclose(fp);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
vocab_cleanup(&ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (ctx.num_words == 0)
|
||||
{
|
||||
fprintf(stderr, "No words found in file\n");
|
||||
vocab_cleanup(&ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Assign ranks by frequency */
|
||||
assign_ranks();
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
/* Inverse mode: find longest excerpt with limited vocabulary */
|
||||
if (max_vocab_mode > 0)
|
||||
{
|
||||
find_longest_excerpt(max_vocab_mode);
|
||||
int best_start = 0;
|
||||
int best_length = 0;
|
||||
vocab_find_longest_excerpt(&ctx, max_vocab_mode, &best_start, &best_length);
|
||||
print_longest_excerpt_result(&ctx, max_vocab_mode, best_start, best_length);
|
||||
|
||||
/* Dump vocabulary if requested */
|
||||
if (dump_vocab)
|
||||
{
|
||||
if (dump_max_rank == 0)
|
||||
dump_max_rank = max_vocab_mode;
|
||||
dump_vocabulary(dump_max_rank);
|
||||
dump_vocabulary(&ctx, dump_max_rank);
|
||||
}
|
||||
|
||||
cleanup();
|
||||
vocab_cleanup(&ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Normal mode: find optimal excerpts */
|
||||
ExcerptResult *results = malloc(max_length * sizeof(ExcerptResult));
|
||||
if (!results)
|
||||
{
|
||||
fprintf(stderr, "Memory allocation failed\n");
|
||||
cleanup();
|
||||
vocab_cleanup(&ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
find_optimal_excerpts(max_length, results);
|
||||
vocab_find_optimal_excerpts(&ctx, max_length, results);
|
||||
print_results(&ctx, results, max_length);
|
||||
|
||||
/* Print results */
|
||||
print_results(results, max_length);
|
||||
|
||||
/* Dump vocabulary if requested */
|
||||
if (dump_vocab)
|
||||
{
|
||||
/* If no max_rank specified, use the max from the excerpt */
|
||||
if (dump_max_rank == 0 && max_length > 0)
|
||||
{
|
||||
dump_max_rank = results[max_length - 1].min_vocab_needed;
|
||||
}
|
||||
if (dump_max_rank > 0)
|
||||
{
|
||||
dump_vocabulary(dump_max_rank);
|
||||
}
|
||||
dump_vocabulary(&ctx, dump_max_rank);
|
||||
}
|
||||
|
||||
/* Cleanup */
|
||||
free(results);
|
||||
cleanup();
|
||||
vocab_cleanup(&ctx);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
627
C/vocabulary_curve/test_vocabulary.c
Normal file
627
C/vocabulary_curve/test_vocabulary.c
Normal file
@ -0,0 +1,627 @@
|
||||
/*
|
||||
* test_vocabulary.c - Unit tests for vocabulary.c
|
||||
*
|
||||
* Tests cover all public functions declared in vocabulary.h using small
|
||||
* in-memory inputs (no file I/O dependency outside vocab_process_stream).
|
||||
*/
|
||||
|
||||
#include "vocabulary.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Helper: build a VocabContext from a literal string.
|
||||
* Returns true on success. */
|
||||
static bool ctx_from_string(VocabContext *ctx, const char *text)
|
||||
{
|
||||
vocab_init(ctx);
|
||||
FILE *fp = fmemopen((void *)text, strlen(text), "r");
|
||||
if (!fp)
|
||||
return false;
|
||||
bool ok = vocab_process_stream(ctx, fp);
|
||||
fclose(fp);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_hash_word */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_hash_word_deterministic(void)
|
||||
{
|
||||
unsigned int h1 = vocab_hash_word("hello");
|
||||
unsigned int h2 = vocab_hash_word("hello");
|
||||
assert(h1 == h2);
|
||||
}
|
||||
|
||||
static void test_hash_word_different(void)
|
||||
{
|
||||
unsigned int h1 = vocab_hash_word("apple");
|
||||
unsigned int h2 = vocab_hash_word("orange");
|
||||
/* Not guaranteed to differ in general, but these definitely do */
|
||||
(void)h1;
|
||||
(void)h2; /* no assertion — just ensure no crash */
|
||||
}
|
||||
|
||||
static void test_hash_word_empty_string(void)
|
||||
{
|
||||
unsigned int h = vocab_hash_word("");
|
||||
assert(h < HASH_SIZE);
|
||||
}
|
||||
|
||||
static void test_hash_word_in_range(void)
|
||||
{
|
||||
unsigned int h = vocab_hash_word("test");
|
||||
assert(h < HASH_SIZE);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_is_word_char */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_is_word_char_alpha(void)
|
||||
{
|
||||
assert(vocab_is_word_char('a'));
|
||||
assert(vocab_is_word_char('Z'));
|
||||
}
|
||||
|
||||
static void test_is_word_char_digit(void)
|
||||
{
|
||||
assert(vocab_is_word_char('0'));
|
||||
assert(vocab_is_word_char('9'));
|
||||
}
|
||||
|
||||
static void test_is_word_char_underscore(void) { assert(vocab_is_word_char('_')); }
|
||||
|
||||
static void test_is_word_char_punctuation(void)
|
||||
{
|
||||
assert(!vocab_is_word_char(' '));
|
||||
assert(!vocab_is_word_char('.'));
|
||||
assert(!vocab_is_word_char(','));
|
||||
assert(!vocab_is_word_char('\n'));
|
||||
}
|
||||
|
||||
static void test_is_word_char_high_byte(void)
|
||||
{
|
||||
/* Characters >= 128 (UTF-8 continuation bytes) are word characters */
|
||||
assert(vocab_is_word_char(200));
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_init / vocab_cleanup */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_init_zeroes_context(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
assert(ctx.num_unique_words == 0);
|
||||
assert(ctx.num_words == 0);
|
||||
}
|
||||
|
||||
static void test_cleanup_resets_counts(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "hello world hello");
|
||||
vocab_cleanup(&ctx);
|
||||
assert(ctx.num_unique_words == 0);
|
||||
assert(ctx.num_words == 0);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_get_or_create_word */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_get_or_create_new_word(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
WordEntry *e = vocab_get_or_create_word(&ctx, "hello");
|
||||
assert(e != NULL);
|
||||
assert(strcmp(e->word, "hello") == 0);
|
||||
assert(ctx.num_unique_words == 1);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_get_or_create_existing_word(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
WordEntry *e1 = vocab_get_or_create_word(&ctx, "hello");
|
||||
WordEntry *e2 = vocab_get_or_create_word(&ctx, "hello");
|
||||
assert(e1 == e2); /* Same pointer */
|
||||
assert(ctx.num_unique_words == 1);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_get_or_create_multiple_words(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
vocab_get_or_create_word(&ctx, "apple");
|
||||
vocab_get_or_create_word(&ctx, "banana");
|
||||
vocab_get_or_create_word(&ctx, "cherry");
|
||||
assert(ctx.num_unique_words == 3);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_process_stream */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_process_stream_basic(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
bool ok = ctx_from_string(&ctx, "the cat sat on the mat");
|
||||
assert(ok);
|
||||
assert(ctx.num_words == 6);
|
||||
assert(ctx.num_unique_words == 5); /* "the" appears twice */
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_process_stream_empty_input(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
bool ok = ctx_from_string(&ctx, "");
|
||||
assert(ok);
|
||||
assert(ctx.num_words == 0);
|
||||
assert(ctx.num_unique_words == 0);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_process_stream_single_word(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
bool ok = ctx_from_string(&ctx, "hello");
|
||||
assert(ok);
|
||||
assert(ctx.num_words == 1);
|
||||
assert(ctx.num_unique_words == 1);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_process_stream_lowercases(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "Hello HELLO hello");
|
||||
/* All three should map to the same "hello" entry */
|
||||
assert(ctx.num_unique_words == 1);
|
||||
assert(ctx.word_sequence[0]->count == 3);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_process_stream_last_word_no_trailing_space(void)
|
||||
{
|
||||
/* Last word has no trailing delimiter */
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "one two three");
|
||||
assert(ctx.num_words == 3);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_process_stream_count_frequency(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "a a a b b c");
|
||||
/* Find the entry for "a" */
|
||||
WordEntry *entry_a = vocab_get_or_create_word(&ctx, "a");
|
||||
assert(entry_a->count == 3);
|
||||
WordEntry *entry_b = vocab_get_or_create_word(&ctx, "b");
|
||||
assert(entry_b->count == 2);
|
||||
WordEntry *entry_c = vocab_get_or_create_word(&ctx, "c");
|
||||
assert(entry_c->count == 1);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* Exercises hash chain traversal using two known-colliding words.
|
||||
* word129 and word2200 both hash to slot 173186 (HASH_SIZE=200003). */
|
||||
static void test_hash_chain_traversal(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
|
||||
WordEntry *e1 = vocab_get_or_create_word(&ctx, "word129");
|
||||
assert(e1 != NULL);
|
||||
assert(ctx.num_unique_words == 1);
|
||||
|
||||
/* This collides with word129 -> exercises entry = entry->next */
|
||||
WordEntry *e2 = vocab_get_or_create_word(&ctx, "word2200");
|
||||
assert(e2 != NULL);
|
||||
assert(e2 != e1);
|
||||
assert(ctx.num_unique_words == 2);
|
||||
|
||||
/* Look up again - exercises chain traversal on find path */
|
||||
WordEntry *e1b = vocab_get_or_create_word(&ctx, "word129");
|
||||
assert(e1b == e1);
|
||||
WordEntry *e2b = vocab_get_or_create_word(&ctx, "word2200");
|
||||
assert(e2b == e2);
|
||||
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* Test that process_stream returns false when num_words is full */
|
||||
static void test_process_stream_too_many_words(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
/* Pre-fill "one" entry so the word is known */
|
||||
WordEntry *dummy = vocab_get_or_create_word(&ctx, "one");
|
||||
assert(dummy != NULL);
|
||||
/* Saturate num_words so the second word overflows */
|
||||
ctx.num_words = MAX_WORDS;
|
||||
/* "one" is already in hash - won't use get_or_create; second word "two" will.
|
||||
* But actually process_stream checks num_words AFTER get_or_create, so we
|
||||
* need the *first* NEW word to trigger overflow.
|
||||
* Let's just pre-fill num_words to MAX_WORDS and start fresh with "two". */
|
||||
ctx.num_words = MAX_WORDS;
|
||||
|
||||
FILE *fp = fmemopen((void *)"two", 3, "r");
|
||||
assert(fp != NULL);
|
||||
bool ok = vocab_process_stream(&ctx, fp);
|
||||
fclose(fp);
|
||||
/* "two" ends without whitespace - handled by last-word branch, which also
|
||||
* checks num_words < MAX_WORDS before inserting (doesn't error).
|
||||
* Re-check: the mid-stream path (line 182) fires on words with trailing
|
||||
* whitespace when num_words >= MAX_WORDS after the get_or_create call. */
|
||||
(void)ok;
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* Cover line 182: return false in mid-stream loop when num_words >= MAX_WORDS */
|
||||
static void test_process_stream_overflow_mid_stream(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
/* Pre-load all MAX_WORDS slots are "used" */
|
||||
ctx.num_words = MAX_WORDS;
|
||||
|
||||
/* Provide "word " (with trailing space) so the loop path (not last-word) fires */
|
||||
FILE *fp = fmemopen((void *)"alpha ", 6, "r");
|
||||
assert(fp != NULL);
|
||||
bool ok = vocab_process_stream(&ctx, fp);
|
||||
fclose(fp);
|
||||
assert(!ok);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* Test get_or_create_word returns NULL when num_unique_words is exhausted */
|
||||
static void test_get_or_create_returns_null_on_overflow(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
ctx.num_unique_words = MAX_UNIQUE_WORDS;
|
||||
WordEntry *e = vocab_get_or_create_word(&ctx, "overflow");
|
||||
assert(e == NULL);
|
||||
}
|
||||
|
||||
/* Test malloc failure path in get_or_create_word */
|
||||
static void test_get_or_create_malloc_failure(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
vocab_test_fail_malloc_count = 1;
|
||||
WordEntry *e = vocab_get_or_create_word(&ctx, "testword");
|
||||
assert(e == NULL);
|
||||
assert(vocab_test_fail_malloc_count == 0);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* Cover line 182: process_stream returns false when get_or_create returns NULL */
|
||||
static void test_process_stream_get_or_create_fails_mid(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
vocab_test_fail_malloc_count = 1;
|
||||
FILE *fp = fmemopen((void *)"newword here", 12, "r");
|
||||
assert(fp != NULL);
|
||||
bool ok = vocab_process_stream(&ctx, fp);
|
||||
fclose(fp);
|
||||
assert(!ok);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* Cover line 202: process_stream returns false when last-word get_or_create fails */
|
||||
static void test_process_stream_get_or_create_fails_last_word(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
vocab_init(&ctx);
|
||||
vocab_test_fail_malloc_count = 1;
|
||||
/* No trailing space - goes to last-word branch */
|
||||
FILE *fp = fmemopen((void *)"justoneword", 11, "r");
|
||||
assert(fp != NULL);
|
||||
bool ok = vocab_process_stream(&ctx, fp);
|
||||
fclose(fp);
|
||||
assert(!ok);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_compare_by_count */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_compare_by_count(void)
|
||||
{
|
||||
WordEntry a = {.count = 5};
|
||||
WordEntry b = {.count = 3};
|
||||
|
||||
const WordEntry *pa = &a;
|
||||
const WordEntry *pb = &b;
|
||||
|
||||
/* a(5) > b(3): compare should return negative (b - a = 3 - 5 = -2 < 0) */
|
||||
int result = vocab_compare_by_count(&pa, &pb);
|
||||
assert(result < 0); /* Descending: higher count should come first */
|
||||
|
||||
int result2 = vocab_compare_by_count(&pb, &pa);
|
||||
assert(result2 > 0);
|
||||
}
|
||||
|
||||
static void test_compare_by_count_equal(void)
|
||||
{
|
||||
WordEntry a = {.count = 4};
|
||||
WordEntry b = {.count = 4};
|
||||
|
||||
const WordEntry *pa = &a;
|
||||
const WordEntry *pb = &b;
|
||||
|
||||
assert(vocab_compare_by_count(&pa, &pb) == 0);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_assign_ranks */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_assign_ranks_basic(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
/* "the" x3, "cat" x2, "sat" x1 */
|
||||
ctx_from_string(&ctx, "the the the cat cat sat");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
WordEntry *the_entry = vocab_get_or_create_word(&ctx, "the");
|
||||
WordEntry *cat_entry = vocab_get_or_create_word(&ctx, "cat");
|
||||
WordEntry *sat_entry = vocab_get_or_create_word(&ctx, "sat");
|
||||
|
||||
assert(the_entry->rank == 1);
|
||||
assert(cat_entry->rank == 2);
|
||||
assert(sat_entry->rank == 3);
|
||||
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_assign_ranks_tied(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
/* "a" x2, "b" x2, "c" x1 */
|
||||
ctx_from_string(&ctx, "a a b b c");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
WordEntry *a_entry = vocab_get_or_create_word(&ctx, "a");
|
||||
WordEntry *b_entry = vocab_get_or_create_word(&ctx, "b");
|
||||
WordEntry *c_entry = vocab_get_or_create_word(&ctx, "c");
|
||||
|
||||
/* a and b both rank 1; c gets rank 3 (competition ranking) */
|
||||
assert(a_entry->rank == 1);
|
||||
assert(b_entry->rank == 1);
|
||||
assert(c_entry->rank == 3);
|
||||
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_analyze_excerpt */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_analyze_excerpt_single_word(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "apple banana cherry");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
int max_rank = vocab_analyze_excerpt(&ctx, 0, 1);
|
||||
assert(max_rank == 1); /* All-unique: first word gets rank 1 */
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_analyze_excerpt_repeated_word(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
/* "the" is most common (rank 1) */
|
||||
ctx_from_string(&ctx, "the cat the dog the");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
/* Excerpt "the the": only uses rank-1 word */
|
||||
int max_rank = vocab_analyze_excerpt(&ctx, 0, 1);
|
||||
assert(max_rank == 1);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_analyze_excerpt_full_text(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
/* Make each word appear a unique number of times so ranks 1..4 are assigned */
|
||||
ctx_from_string(&ctx, "a a a a b b b c c d");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
/* Full 10-word excerpt: needs rank 4 (word "d" appears once, rank 4) */
|
||||
int max_rank = vocab_analyze_excerpt(&ctx, 0, 10);
|
||||
assert(max_rank == 4);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_find_optimal_excerpts */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_find_optimal_excerpts_length1(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
/* "the" most frequent (rank 1); best 1-word excerpt uses only rank-1 word */
|
||||
ctx_from_string(&ctx, "the the the cat dog");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
ExcerptResult results[1];
|
||||
vocab_find_optimal_excerpts(&ctx, 1, results);
|
||||
|
||||
assert(results[0].excerpt_length == 1);
|
||||
assert(results[0].min_vocab_needed == 1); /* Best excerpt is "the" */
|
||||
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_find_optimal_excerpts_monotone(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "the cat sat on the mat");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
int max_length = 4;
|
||||
ExcerptResult results[4];
|
||||
vocab_find_optimal_excerpts(&ctx, max_length, results);
|
||||
|
||||
/* Vocab needed should be >= previous (weakly monotone) */
|
||||
for (int i = 1; i < max_length; i++)
|
||||
{
|
||||
assert(results[i].min_vocab_needed >= results[i - 1].min_vocab_needed);
|
||||
}
|
||||
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* vocab_find_longest_excerpt */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
static void test_find_longest_excerpt_unlimited(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "the cat sat on the mat");
|
||||
vocab_assign_ranks(&ctx);
|
||||
|
||||
int start = 0;
|
||||
int length = 0;
|
||||
/* All 5 unique words have ranks 1..5; max_vocab >= 5 means all qualify */
|
||||
vocab_find_longest_excerpt(&ctx, 5, &start, &length);
|
||||
assert(length == 6); /* Entire text */
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_find_longest_excerpt_restrictive(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
/* "rare" has rank 5; with max_vocab=1 it can't appear */
|
||||
ctx_from_string(&ctx, "the the the rare the the");
|
||||
vocab_assign_ranks(&ctx);
|
||||
/* "the" rank 1, "rare" rank 2 */
|
||||
|
||||
int start = 0;
|
||||
int length = 0;
|
||||
vocab_find_longest_excerpt(&ctx, 1, &start, &length);
|
||||
/* Best run is "the the the" (3 words) before "rare" */
|
||||
assert(length == 3);
|
||||
assert(start == 0);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_find_longest_excerpt_no_valid(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
ctx_from_string(&ctx, "rare word here");
|
||||
vocab_assign_ranks(&ctx);
|
||||
/* All words rank >= 1; with max_vocab=0 nothing can qualify */
|
||||
|
||||
int start = 0;
|
||||
int length = 0;
|
||||
vocab_find_longest_excerpt(&ctx, 0, &start, &length);
|
||||
assert(length == 0);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
static void test_find_longest_excerpt_mid_sequence(void)
|
||||
{
|
||||
VocabContext ctx;
|
||||
/* "rare" appears twice (rank 1 due to count=2),
|
||||
* "odd" appears once (rank 2)
|
||||
* sequence: odd rare rare rare odd
|
||||
* With max_vocab=1 (only "rare"):
|
||||
* window spans positions 1,2,3 -> length 3 */
|
||||
ctx_from_string(&ctx, "odd rare rare rare odd");
|
||||
vocab_assign_ranks(&ctx);
|
||||
/* "rare" has count 3 -> rank 1; "odd" has count 2 -> rank 2 */
|
||||
|
||||
int start = 0;
|
||||
int length = 0;
|
||||
vocab_find_longest_excerpt(&ctx, 1, &start, &length);
|
||||
assert(length == 3);
|
||||
assert(start == 1);
|
||||
vocab_cleanup(&ctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Main */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* vocab_hash_word */
|
||||
test_hash_word_deterministic();
|
||||
test_hash_word_different();
|
||||
test_hash_word_empty_string();
|
||||
test_hash_word_in_range();
|
||||
|
||||
/* vocab_is_word_char */
|
||||
test_is_word_char_alpha();
|
||||
test_is_word_char_digit();
|
||||
test_is_word_char_underscore();
|
||||
test_is_word_char_punctuation();
|
||||
test_is_word_char_high_byte();
|
||||
|
||||
/* vocab_init / vocab_cleanup */
|
||||
test_init_zeroes_context();
|
||||
test_cleanup_resets_counts();
|
||||
|
||||
/* vocab_get_or_create_word */
|
||||
test_get_or_create_new_word();
|
||||
test_get_or_create_existing_word();
|
||||
test_get_or_create_multiple_words();
|
||||
test_get_or_create_returns_null_on_overflow();
|
||||
test_get_or_create_malloc_failure();
|
||||
|
||||
/* vocab_process_stream */
|
||||
test_process_stream_basic();
|
||||
test_process_stream_empty_input();
|
||||
test_process_stream_single_word();
|
||||
test_process_stream_lowercases();
|
||||
test_process_stream_last_word_no_trailing_space();
|
||||
test_process_stream_count_frequency();
|
||||
test_hash_chain_traversal();
|
||||
test_process_stream_too_many_words();
|
||||
test_process_stream_overflow_mid_stream();
|
||||
test_process_stream_get_or_create_fails_mid();
|
||||
test_process_stream_get_or_create_fails_last_word();
|
||||
|
||||
/* vocab_compare_by_count */
|
||||
test_compare_by_count();
|
||||
test_compare_by_count_equal();
|
||||
|
||||
/* vocab_assign_ranks */
|
||||
test_assign_ranks_basic();
|
||||
test_assign_ranks_tied();
|
||||
|
||||
/* vocab_analyze_excerpt */
|
||||
test_analyze_excerpt_single_word();
|
||||
test_analyze_excerpt_repeated_word();
|
||||
test_analyze_excerpt_full_text();
|
||||
|
||||
/* vocab_find_optimal_excerpts */
|
||||
test_find_optimal_excerpts_length1();
|
||||
test_find_optimal_excerpts_monotone();
|
||||
|
||||
/* vocab_find_longest_excerpt */
|
||||
test_find_longest_excerpt_unlimited();
|
||||
test_find_longest_excerpt_restrictive();
|
||||
test_find_longest_excerpt_no_valid();
|
||||
test_find_longest_excerpt_mid_sequence();
|
||||
|
||||
printf("All tests passed (%d tests).\n", 40);
|
||||
return 0;
|
||||
}
|
||||
281
C/vocabulary_curve/vocabulary.c
Normal file
281
C/vocabulary_curve/vocabulary.c
Normal file
@ -0,0 +1,281 @@
|
||||
/*
|
||||
* vocabulary.c - Core vocabulary analysis logic.
|
||||
*/
|
||||
#include "vocabulary.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Test hook: test code can set this to make the next N malloc calls fail */
|
||||
int vocab_test_fail_malloc_count = 0;
|
||||
|
||||
static void *vocab_malloc(size_t size)
|
||||
{
|
||||
if (vocab_test_fail_malloc_count > 0)
|
||||
{
|
||||
vocab_test_fail_malloc_count--;
|
||||
return NULL;
|
||||
}
|
||||
return malloc(size);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Initialise / cleanup */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
void vocab_init(VocabContext *ctx)
|
||||
{
|
||||
memset(ctx->hash_table, 0, sizeof(ctx->hash_table));
|
||||
ctx->num_unique_words = 0;
|
||||
ctx->num_words = 0;
|
||||
}
|
||||
|
||||
void vocab_cleanup(VocabContext *ctx)
|
||||
{
|
||||
for (int i = 0; i < ctx->num_unique_words; i++)
|
||||
{
|
||||
free(ctx->all_entries[i]);
|
||||
}
|
||||
ctx->num_unique_words = 0;
|
||||
ctx->num_words = 0;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Hash table helpers */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
unsigned int vocab_hash_word(const char *word)
|
||||
{
|
||||
unsigned int hash = 5381;
|
||||
int c;
|
||||
while ((c = *word++))
|
||||
{
|
||||
hash = ((hash << 5) + hash) + (unsigned int)c;
|
||||
}
|
||||
return hash % HASH_SIZE;
|
||||
}
|
||||
|
||||
WordEntry *vocab_get_or_create_word(VocabContext *ctx, const char *word)
|
||||
{
|
||||
unsigned int h = vocab_hash_word(word);
|
||||
WordEntry *entry = ctx->hash_table[h];
|
||||
|
||||
while (entry)
|
||||
{
|
||||
if (strcmp(entry->word, word) == 0)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
entry = entry->next;
|
||||
}
|
||||
|
||||
/* Create new entry */
|
||||
if (ctx->num_unique_words >= MAX_UNIQUE_WORDS)
|
||||
{
|
||||
fprintf(stderr, "Too many unique words\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
entry = vocab_malloc(sizeof(WordEntry));
|
||||
if (!entry)
|
||||
{
|
||||
fprintf(stderr, "Memory allocation failed\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
strncpy(entry->word, word, MAX_WORD_LEN - 1);
|
||||
entry->word[MAX_WORD_LEN - 1] = '\0';
|
||||
entry->count = 0;
|
||||
entry->rank = 0;
|
||||
entry->next = ctx->hash_table[h];
|
||||
ctx->hash_table[h] = entry;
|
||||
|
||||
ctx->all_entries[ctx->num_unique_words++] = entry;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Character classification */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
bool vocab_is_word_char(int c) { return isalnum(c) || c == '_' || (unsigned char)c >= 128; }
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Sorting / ranking */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
int vocab_compare_by_count(const void *a, const void *b)
|
||||
{
|
||||
const WordEntry *wa = *(const WordEntry **)a;
|
||||
const WordEntry *wb = *(const WordEntry **)b;
|
||||
return wb->count - wa->count; /* Descending */
|
||||
}
|
||||
|
||||
void vocab_assign_ranks(VocabContext *ctx)
|
||||
{
|
||||
qsort(ctx->all_entries, ctx->num_unique_words, sizeof(WordEntry *), vocab_compare_by_count);
|
||||
|
||||
for (int i = 0; i < ctx->num_unique_words; i++)
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
ctx->all_entries[i]->rank = 1;
|
||||
}
|
||||
else if (ctx->all_entries[i]->count == ctx->all_entries[i - 1]->count)
|
||||
{
|
||||
ctx->all_entries[i]->rank = ctx->all_entries[i - 1]->rank;
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx->all_entries[i]->rank = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Sliding-window analysis */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
int vocab_analyze_excerpt(const VocabContext *ctx, int start, int length)
|
||||
{
|
||||
static bool seen_rank[MAX_UNIQUE_WORDS + 1];
|
||||
memset(seen_rank, 0, (ctx->num_unique_words + 1) * sizeof(bool));
|
||||
|
||||
int max_rank = 0;
|
||||
|
||||
for (int i = start; i < start + length; i++)
|
||||
{
|
||||
WordEntry *entry = ctx->word_sequence[i];
|
||||
int rank = entry->rank;
|
||||
|
||||
if (!seen_rank[rank])
|
||||
{
|
||||
seen_rank[rank] = true;
|
||||
if (rank > max_rank)
|
||||
{
|
||||
max_rank = rank;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return max_rank;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* File I/O */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
bool vocab_process_stream(VocabContext *ctx, FILE *fp)
|
||||
{
|
||||
char word[MAX_WORD_LEN];
|
||||
int word_len = 0;
|
||||
int c;
|
||||
|
||||
while ((c = fgetc(fp)) != EOF)
|
||||
{
|
||||
if (vocab_is_word_char(c))
|
||||
{
|
||||
if (word_len < MAX_WORD_LEN - 1)
|
||||
{
|
||||
word[word_len++] = tolower(c);
|
||||
}
|
||||
}
|
||||
else if (word_len > 0)
|
||||
{
|
||||
word[word_len] = '\0';
|
||||
|
||||
WordEntry *entry = vocab_get_or_create_word(ctx, word);
|
||||
if (!entry)
|
||||
return false;
|
||||
entry->count++;
|
||||
|
||||
if (ctx->num_words >= MAX_WORDS)
|
||||
{
|
||||
fprintf(stderr, "Too many words in file\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx->word_sequence[ctx->num_words++] = entry;
|
||||
word_len = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle last word if file doesn't end with whitespace */
|
||||
if (word_len > 0)
|
||||
{
|
||||
word[word_len] = '\0';
|
||||
WordEntry *entry = vocab_get_or_create_word(ctx, word);
|
||||
if (!entry)
|
||||
return false;
|
||||
entry->count++;
|
||||
|
||||
if (ctx->num_words < MAX_WORDS)
|
||||
{
|
||||
ctx->word_sequence[ctx->num_words++] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Optimal-excerpt search */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
void vocab_find_optimal_excerpts(const VocabContext *ctx, int max_length, ExcerptResult *results)
|
||||
{
|
||||
for (int length = 1; length <= max_length && length <= ctx->num_words; length++)
|
||||
{
|
||||
int best_vocab = ctx->num_unique_words + 1;
|
||||
int best_start = 0;
|
||||
|
||||
for (int start = 0; start <= ctx->num_words - length; start++)
|
||||
{
|
||||
int vocab_needed = vocab_analyze_excerpt(ctx, start, length);
|
||||
|
||||
if (vocab_needed < best_vocab)
|
||||
{
|
||||
best_vocab = vocab_needed;
|
||||
best_start = start;
|
||||
}
|
||||
}
|
||||
|
||||
results[length - 1].excerpt_length = length;
|
||||
results[length - 1].min_vocab_needed = best_vocab;
|
||||
results[length - 1].start_pos = best_start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Inverse mode */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
void vocab_find_longest_excerpt(const VocabContext *ctx, int max_vocab, int *out_start,
|
||||
int *out_length)
|
||||
{
|
||||
int best_start = 0;
|
||||
int best_length = 0;
|
||||
|
||||
int left = 0;
|
||||
for (int right = 0; right < ctx->num_words; right++)
|
||||
{
|
||||
if (ctx->word_sequence[right]->rank > max_vocab)
|
||||
{
|
||||
left = right + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
int length = right - left + 1;
|
||||
if (length > best_length)
|
||||
{
|
||||
best_length = length;
|
||||
best_start = left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*out_start = best_start;
|
||||
*out_length = best_length;
|
||||
}
|
||||
78
C/vocabulary_curve/vocabulary.h
Normal file
78
C/vocabulary_curve/vocabulary.h
Normal file
@ -0,0 +1,78 @@
|
||||
/*
|
||||
* vocabulary.h - Core vocabulary analysis logic, extracted for testability.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define MAX_WORD_LEN 64
|
||||
#define MAX_WORDS 500000
|
||||
#define MAX_UNIQUE_WORDS 100000
|
||||
#define HASH_SIZE 200003 /* Prime number for better distribution */
|
||||
|
||||
/* Word entry for hash table */
|
||||
typedef struct WordEntry
|
||||
{
|
||||
char word[MAX_WORD_LEN];
|
||||
int count;
|
||||
int rank; /* 1-indexed rank by frequency (1 = most common) */
|
||||
struct WordEntry *next;
|
||||
} WordEntry;
|
||||
|
||||
/* Result for each excerpt length */
|
||||
typedef struct
|
||||
{
|
||||
int excerpt_length;
|
||||
int min_vocab_needed;
|
||||
int start_pos; /* Start position in word_sequence */
|
||||
} ExcerptResult;
|
||||
|
||||
/* Context holding all mutable state (replaces static globals) */
|
||||
typedef struct
|
||||
{
|
||||
WordEntry *hash_table[HASH_SIZE];
|
||||
WordEntry *all_entries[MAX_UNIQUE_WORDS];
|
||||
int num_unique_words;
|
||||
WordEntry *word_sequence[MAX_WORDS];
|
||||
int num_words;
|
||||
} VocabContext;
|
||||
|
||||
/* Initialise a fresh context (zero everything) */
|
||||
void vocab_init(VocabContext *ctx);
|
||||
|
||||
/* Free all allocated WordEntry nodes inside ctx */
|
||||
void vocab_cleanup(VocabContext *ctx);
|
||||
|
||||
/* Hash a word (public for tests) */
|
||||
unsigned int vocab_hash_word(const char *word);
|
||||
|
||||
/* Find or create a word entry in the context */
|
||||
WordEntry *vocab_get_or_create_word(VocabContext *ctx, const char *word);
|
||||
|
||||
/* Check if a character can be part of a word */
|
||||
bool vocab_is_word_char(int c);
|
||||
|
||||
/* Comparator for qsort (descending count) */
|
||||
int vocab_compare_by_count(const void *a, const void *b);
|
||||
|
||||
/* Assign frequency ranks to all entries in ctx */
|
||||
void vocab_assign_ranks(VocabContext *ctx);
|
||||
|
||||
/* Analyse one excerpt window and return the max rank required */
|
||||
int vocab_analyze_excerpt(const VocabContext *ctx, int start, int length);
|
||||
|
||||
/* Read and index words from an open FILE stream into ctx */
|
||||
bool vocab_process_stream(VocabContext *ctx, FILE *fp);
|
||||
|
||||
/* Find optimal excerpts for lengths 1..max_length; results[] must be
|
||||
* pre-allocated to max_length elements */
|
||||
void vocab_find_optimal_excerpts(const VocabContext *ctx, int max_length, ExcerptResult *results);
|
||||
|
||||
/* Inverse mode: find longest contiguous excerpt using only top-N vocab */
|
||||
void vocab_find_longest_excerpt(const VocabContext *ctx, int max_vocab, int *out_start,
|
||||
int *out_length);
|
||||
|
||||
/* Test hook: set to non-zero to make the next malloc call(s) return NULL.
|
||||
* Only used by test_vocabulary.c to exercise the malloc-failure path. */
|
||||
extern int vocab_test_fail_malloc_count;
|
||||
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
CXX := g++
|
||||
CXX := g++
|
||||
CXXFLAGS := -O2 -Wall -Wextra -std=c++17
|
||||
LDFLAGS :=
|
||||
LDFLAGS :=
|
||||
|
||||
BINS := howOftenDoesCharOccur quickchallenges reverseString solveQuadraticEquation
|
||||
|
||||
@ -18,6 +18,54 @@ reverseString: reverseString.cpp
|
||||
solveQuadraticEquation: solveQuadraticEquation.cpp
|
||||
$(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
# ---- Coverage build: separate compilation so gcov data is per source file ----
|
||||
COV := -O2 -g --coverage -Wall -Wextra -std=c++17 -DTESTING
|
||||
|
||||
howOftenDoesCharOccur.o: howOftenDoesCharOccur.cpp
|
||||
$(CXX) $(COV) -c -o $@ $<
|
||||
|
||||
quickchallenges.o: quickchallenges.cpp
|
||||
$(CXX) $(COV) -c -o $@ $<
|
||||
|
||||
reverseString.o: reverseString.cpp
|
||||
$(CXX) $(COV) -c -o $@ $<
|
||||
|
||||
solveQuadraticEquation.o: solveQuadraticEquation.cpp
|
||||
$(CXX) $(COV) -c -o $@ $<
|
||||
|
||||
test_challenges.o: test_challenges.cpp
|
||||
$(CXX) $(COV) -c -o $@ $<
|
||||
|
||||
TEST_OBJS := test_challenges.o howOftenDoesCharOccur.o quickchallenges.o reverseString.o solveQuadraticEquation.o
|
||||
|
||||
test_challenges: $(TEST_OBJS)
|
||||
$(CXX) -O2 -g --coverage -o $@ $^
|
||||
|
||||
test: test_challenges
|
||||
./test_challenges
|
||||
|
||||
coverage: test_challenges
|
||||
./test_challenges
|
||||
lcov --capture --directory . --output-file coverage.info \
|
||||
--rc branch_coverage=1 --ignore-errors inconsistent,mismatch,unused
|
||||
lcov --remove coverage.info '/usr/*' --output-file coverage.info \
|
||||
--rc branch_coverage=1 --ignore-errors unused,inconsistent
|
||||
lcov --extract coverage.info \
|
||||
"$(CURDIR)/howOftenDoesCharOccur.cpp" \
|
||||
"$(CURDIR)/quickchallenges.cpp" \
|
||||
"$(CURDIR)/reverseString.cpp" \
|
||||
"$(CURDIR)/solveQuadraticEquation.cpp" \
|
||||
--output-file coverage.info \
|
||||
--ignore-errors unused,inconsistent
|
||||
@echo "--- Coverage Summary ---"
|
||||
lcov --summary coverage.info --rc branch_coverage=1 2>&1 | tee /tmp/lcov_summary.txt
|
||||
@LINE_COV=$$(grep "lines" /tmp/lcov_summary.txt | grep -oP '[0-9]+\.[0-9]+(?=%)'); \
|
||||
echo "Line coverage: $${LINE_COV}%"; \
|
||||
if [ "$$(echo "$${LINE_COV} < 100.0" | bc)" = "1" ]; then \
|
||||
echo "FAIL: line coverage below 100%"; exit 1; \
|
||||
fi
|
||||
@echo "OK: 100% line coverage achieved"
|
||||
|
||||
run: all
|
||||
./howOftenDoesCharOccur
|
||||
./quickchallenges
|
||||
@ -25,6 +73,6 @@ run: all
|
||||
./solveQuadraticEquation
|
||||
|
||||
clean:
|
||||
rm -f $(BINS)
|
||||
rm -f $(BINS) test_challenges *.gcda *.gcno *.gcov coverage.info *.o
|
||||
|
||||
.PHONY: all run clean
|
||||
.PHONY: all run clean test coverage
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
#include "howOftenDoesCharOccur.h"
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
struct charOccurence {
|
||||
char c;
|
||||
int occurrence;
|
||||
};
|
||||
|
||||
void printCharOccurenceVector(const std::vector<charOccurence> v) {
|
||||
std::cout << "[";
|
||||
for (unsigned int i = 0; i < v.size(); i++) {
|
||||
@ -15,25 +11,34 @@ void printCharOccurenceVector(const std::vector<charOccurence> v) {
|
||||
std::cout << "]" << std::endl;
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::vector<charOccurence> computeCharOccurences(const std::string &userInput) {
|
||||
std::vector<charOccurence> list;
|
||||
std::string userInput = "aaaabbbcca";
|
||||
charOccurence newCharOccurence;
|
||||
newCharOccurence.c = userInput.at(0);
|
||||
newCharOccurence.occurrence = 1;
|
||||
for (unsigned int i = 1, j = 1; i < userInput.length(); i++) {
|
||||
char newCharacter = userInput.at(i);
|
||||
if (newCharacter != newCharOccurence.c) {
|
||||
list.push_back(newCharOccurence);
|
||||
j = 1;
|
||||
newCharOccurence.c = newCharacter;
|
||||
newCharOccurence.occurrence = j;
|
||||
|
||||
} else {
|
||||
newCharOccurence.occurrence++;
|
||||
if (!userInput.empty()) {
|
||||
charOccurence newCharOccurence;
|
||||
newCharOccurence.c = userInput.at(0);
|
||||
newCharOccurence.occurrence = 1;
|
||||
for (unsigned int i = 1, j = 1; i < userInput.length(); i++) {
|
||||
char newCharacter = userInput.at(i);
|
||||
if (newCharacter != newCharOccurence.c) {
|
||||
list.push_back(newCharOccurence);
|
||||
j = 1;
|
||||
newCharOccurence.c = newCharacter;
|
||||
newCharOccurence.occurrence = j;
|
||||
} else {
|
||||
newCharOccurence.occurrence++;
|
||||
}
|
||||
}
|
||||
list.push_back(newCharOccurence);
|
||||
}
|
||||
list.push_back(newCharOccurence);
|
||||
auto result = list;
|
||||
return result;
|
||||
}
|
||||
|
||||
#ifndef TESTING
|
||||
int main() {
|
||||
std::string userInput = "aaaabbbcca";
|
||||
std::vector<charOccurence> list = computeCharOccurences(userInput);
|
||||
printCharOccurenceVector(list);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
11
CPP/miscelanious/howOftenDoesCharOccur.h
Normal file
11
CPP/miscelanious/howOftenDoesCharOccur.h
Normal file
@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct charOccurence {
|
||||
char c;
|
||||
int occurrence;
|
||||
};
|
||||
|
||||
void printCharOccurenceVector(const std::vector<charOccurence> v);
|
||||
std::vector<charOccurence> computeCharOccurences(const std::string &userInput);
|
||||
@ -1,3 +1,4 @@
|
||||
#include "quickchallenges.h"
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
@ -9,6 +10,7 @@ int sumStartEnd(int start, int end) {
|
||||
return sum;
|
||||
}
|
||||
|
||||
#ifndef TESTING
|
||||
int main() {
|
||||
std::cout << "Krzysztof" << std::endl;
|
||||
for (int i = 700; i >= 200; i -= 13)
|
||||
@ -97,3 +99,4 @@ int main() {
|
||||
else
|
||||
std::cout << GRADES[3];
|
||||
}
|
||||
#endif
|
||||
|
||||
3
CPP/miscelanious/quickchallenges.h
Normal file
3
CPP/miscelanious/quickchallenges.h
Normal file
@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
int sumStartEnd(int start, int end);
|
||||
@ -1,20 +1,29 @@
|
||||
#include "reverseString.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
std::string reverseStringManual(const std::string &s) {
|
||||
std::string result = s;
|
||||
int sLength = static_cast<int>(result.length());
|
||||
for (int i = 0; i < sLength / 2; i++) {
|
||||
char temp = result[sLength - 1 - i];
|
||||
result[sLength - 1 - i] = result[i];
|
||||
result[i] = temp;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
#ifndef TESTING
|
||||
int main() {
|
||||
std::string userString;
|
||||
getline(std::cin, userString);
|
||||
int sLength = userString.length();
|
||||
std::string tempString = userString;
|
||||
for (int i = 0; i < sLength / 2; i++) {
|
||||
char temp = tempString[sLength - 1 - i];
|
||||
tempString[sLength - 1 - i] = tempString[i];
|
||||
tempString[i] = temp;
|
||||
}
|
||||
reverse(userString.begin(), userString.end());
|
||||
bool correct = tempString == userString;
|
||||
std::string tempString = reverseStringManual(userString);
|
||||
std::string stdReversed = userString;
|
||||
reverse(stdReversed.begin(), stdReversed.end());
|
||||
bool correct = tempString == stdReversed;
|
||||
std::cout << correct << std::endl;
|
||||
std::cout << tempString << std::endl;
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
4
CPP/miscelanious/reverseString.h
Normal file
4
CPP/miscelanious/reverseString.h
Normal file
@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
std::string reverseStringManual(const std::string &s);
|
||||
@ -1,3 +1,4 @@
|
||||
#include "solveQuadraticEquation.h"
|
||||
#include <iostream>
|
||||
#include <math.h>
|
||||
#include <string>
|
||||
@ -18,6 +19,7 @@ float calculateSecondTerm(float a, float b, float delta) {
|
||||
return (-b + sqrt(delta)) / (2 * a);
|
||||
}
|
||||
|
||||
#ifndef TESTING
|
||||
int main() {
|
||||
print(START);
|
||||
float a, b, c;
|
||||
@ -37,3 +39,4 @@ int main() {
|
||||
std::cout << "x_2 = " << x_2 << std::endl;
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
7
CPP/miscelanious/solveQuadraticEquation.h
Normal file
7
CPP/miscelanious/solveQuadraticEquation.h
Normal file
@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
void print(const std::string s);
|
||||
float getDelta(float a, float b, float c);
|
||||
float calculateFirstTerm(float a, float b, float delta);
|
||||
float calculateSecondTerm(float a, float b, float delta);
|
||||
198
CPP/miscelanious/test_challenges.cpp
Normal file
198
CPP/miscelanious/test_challenges.cpp
Normal file
@ -0,0 +1,198 @@
|
||||
/* Tests for CPP/miscelanious: all testable functions from 4 source files */
|
||||
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Include headers (not source files) - compiled separately via Makefile
|
||||
* ----------------------------------------------------------------------- */
|
||||
#include "howOftenDoesCharOccur.h"
|
||||
#include "quickchallenges.h"
|
||||
#include "reverseString.h"
|
||||
#include "solveQuadraticEquation.h"
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Helper
|
||||
* ----------------------------------------------------------------------- */
|
||||
static bool nearlyEqual(float a, float b, float eps = 1e-4f) {
|
||||
return std::fabs(a - b) < eps;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* quickchallenges: sumStartEnd
|
||||
* ----------------------------------------------------------------------- */
|
||||
static void test_sumStartEnd() {
|
||||
assert(sumStartEnd(0, 1000) == 500500);
|
||||
assert(sumStartEnd(1, 10) == 55);
|
||||
assert(sumStartEnd(0, 0) == 0);
|
||||
assert(sumStartEnd(5, 5) == 5);
|
||||
assert(sumStartEnd(-3, 3) == 0);
|
||||
assert(sumStartEnd(1, 1) == 1);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* solveQuadraticEquation: getDelta, calculateFirstTerm, calculateSecondTerm
|
||||
* ----------------------------------------------------------------------- */
|
||||
static void test_getDelta() {
|
||||
/* x^2 - 5x + 6 = 0: a=1, b=-5, c=6 => delta = 25 - 24 = 1 */
|
||||
assert(nearlyEqual(getDelta(1.0f, -5.0f, 6.0f), 1.0f));
|
||||
|
||||
/* x^2 + 2x + 1 = 0: delta = 4 - 4 = 0 */
|
||||
assert(nearlyEqual(getDelta(1.0f, 2.0f, 1.0f), 0.0f));
|
||||
|
||||
/* x^2 + x + 1 = 0: delta = 1 - 4 = -3 */
|
||||
assert(nearlyEqual(getDelta(1.0f, 1.0f, 1.0f), -3.0f));
|
||||
|
||||
/* 2x^2 - 4x + 0 = 0: delta = 16 - 0 = 16 */
|
||||
assert(nearlyEqual(getDelta(2.0f, -4.0f, 0.0f), 16.0f));
|
||||
}
|
||||
|
||||
static void test_calculateFirstTerm() {
|
||||
/* x^2 - 5x + 6 = 0: roots 2 and 3 */
|
||||
float delta = getDelta(1.0f, -5.0f, 6.0f);
|
||||
float x1 = calculateFirstTerm(1.0f, -5.0f, delta);
|
||||
float x2 = calculateSecondTerm(1.0f, -5.0f, delta);
|
||||
assert(nearlyEqual(x1, 2.0f));
|
||||
assert(nearlyEqual(x2, 3.0f));
|
||||
}
|
||||
|
||||
static void test_calculateSecondTerm() {
|
||||
/* x^2 + 2x + 1 = 0: double root -1 */
|
||||
float delta = getDelta(1.0f, 2.0f, 1.0f);
|
||||
float x1 = calculateFirstTerm(1.0f, 2.0f, delta);
|
||||
float x2 = calculateSecondTerm(1.0f, 2.0f, delta);
|
||||
assert(nearlyEqual(x1, -1.0f));
|
||||
assert(nearlyEqual(x2, -1.0f));
|
||||
}
|
||||
|
||||
static void test_quadratic_large() {
|
||||
/* 2x^2 - 4x = 0: roots 0 and 2 */
|
||||
float delta = getDelta(2.0f, -4.0f, 0.0f);
|
||||
float x1 = calculateFirstTerm(2.0f, -4.0f, delta);
|
||||
float x2 = calculateSecondTerm(2.0f, -4.0f, delta);
|
||||
/* smaller root first */
|
||||
assert(nearlyEqual(x1, 0.0f));
|
||||
assert(nearlyEqual(x2, 2.0f));
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* reverseString: reverseStringManual
|
||||
* ----------------------------------------------------------------------- */
|
||||
static void test_reverseStringManual() {
|
||||
assert(reverseStringManual("hello") == "olleh");
|
||||
assert(reverseStringManual("abcde") == "edcba");
|
||||
assert(reverseStringManual("abcd") == "dcba");
|
||||
assert(reverseStringManual("a") == "a");
|
||||
assert(reverseStringManual("") == "");
|
||||
assert(reverseStringManual("ab") == "ba");
|
||||
assert(reverseStringManual("racecar") == "racecar");
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* howOftenDoesCharOccur: computeCharOccurences, printCharOccurenceVector
|
||||
* ----------------------------------------------------------------------- */
|
||||
static void test_computeCharOccurences_basic() {
|
||||
auto v = computeCharOccurences("aaaabbbcca");
|
||||
assert(v.size() == 4);
|
||||
assert(v[0].c == 'a' && v[0].occurrence == 4);
|
||||
assert(v[1].c == 'b' && v[1].occurrence == 3);
|
||||
assert(v[2].c == 'c' && v[2].occurrence == 2);
|
||||
assert(v[3].c == 'a' && v[3].occurrence == 1);
|
||||
}
|
||||
|
||||
static void test_computeCharOccurences_single() {
|
||||
auto v = computeCharOccurences("x");
|
||||
assert(v.size() == 1);
|
||||
assert(v[0].c == 'x' && v[0].occurrence == 1);
|
||||
}
|
||||
|
||||
static void test_computeCharOccurences_empty() {
|
||||
auto v = computeCharOccurences("");
|
||||
assert(v.empty());
|
||||
}
|
||||
|
||||
static void test_computeCharOccurences_all_same() {
|
||||
auto v = computeCharOccurences("zzzz");
|
||||
assert(v.size() == 1);
|
||||
assert(v[0].c == 'z' && v[0].occurrence == 4);
|
||||
}
|
||||
|
||||
static void test_computeCharOccurences_alternating() {
|
||||
auto v = computeCharOccurences("ababab");
|
||||
assert(v.size() == 6);
|
||||
for (auto &e : v) {
|
||||
assert(e.occurrence == 1);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_printCharOccurenceVector_output() {
|
||||
auto v = computeCharOccurences("aab");
|
||||
/* Capture stdout */
|
||||
std::streambuf *old = std::cout.rdbuf();
|
||||
std::ostringstream oss;
|
||||
std::cout.rdbuf(oss.rdbuf());
|
||||
printCharOccurenceVector(v);
|
||||
std::cout.rdbuf(old);
|
||||
std::string out = oss.str();
|
||||
assert(out.find("\"a\"") != std::string::npos);
|
||||
assert(out.find("\"b\"") != std::string::npos);
|
||||
assert(out.find('[') != std::string::npos);
|
||||
assert(out.find(']') != std::string::npos);
|
||||
}
|
||||
|
||||
static void test_printCharOccurenceVector_single() {
|
||||
std::vector<charOccurence> v;
|
||||
charOccurence e;
|
||||
e.c = 'x';
|
||||
e.occurrence = 3;
|
||||
v.push_back(e);
|
||||
std::streambuf *old = std::cout.rdbuf();
|
||||
std::ostringstream oss;
|
||||
std::cout.rdbuf(oss.rdbuf());
|
||||
printCharOccurenceVector(v);
|
||||
std::cout.rdbuf(old);
|
||||
std::string out = oss.str();
|
||||
assert(out.find("\"x\"") != std::string::npos);
|
||||
assert(out.find("3") != std::string::npos);
|
||||
}
|
||||
|
||||
static void test_print_function() {
|
||||
/* print() is called inside main() - test it directly via output capture */
|
||||
std::streambuf *old = std::cout.rdbuf();
|
||||
std::ostringstream oss;
|
||||
std::cout.rdbuf(oss.rdbuf());
|
||||
print("hello test");
|
||||
print("Enter quadratic equation constants: a, b, c as in: ax^2 + bx + c = 0");
|
||||
std::cout.rdbuf(old);
|
||||
assert(oss.str().find("hello test") != std::string::npos);
|
||||
assert(oss.str().find("Enter quadratic equation constants") !=
|
||||
std::string::npos);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* main
|
||||
* ----------------------------------------------------------------------- */
|
||||
int main() {
|
||||
test_sumStartEnd();
|
||||
test_getDelta();
|
||||
test_calculateFirstTerm();
|
||||
test_calculateSecondTerm();
|
||||
test_quadratic_large();
|
||||
test_reverseStringManual();
|
||||
test_computeCharOccurences_basic();
|
||||
test_computeCharOccurences_single();
|
||||
test_computeCharOccurences_empty();
|
||||
test_computeCharOccurences_all_same();
|
||||
test_computeCharOccurences_alternating();
|
||||
test_printCharOccurenceVector_output();
|
||||
test_printCharOccurenceVector_single();
|
||||
test_print_function();
|
||||
|
||||
std::cout << "All tests passed!\n";
|
||||
return 0;
|
||||
}
|
||||
2545
TS/battery-status/package-lock.json
generated
2545
TS/battery-status/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,17 +7,24 @@
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --strictPort --port 5173"
|
||||
"preview": "vite preview --strictPort --port 5173",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"jsdom": "^29.0.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.1"
|
||||
"vite": "^5.4.1",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
152
TS/battery-status/src/App.test.tsx
Normal file
152
TS/battery-status/src/App.test.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup } from '@testing-library/react'
|
||||
import { App } from './App'
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('./useBattery')
|
||||
vi.mock('./useBeforeUnload')
|
||||
|
||||
import { useBattery } from './useBattery'
|
||||
import { useBeforeUnload } from './useBeforeUnload'
|
||||
|
||||
const mockUseBattery = vi.mocked(useBattery)
|
||||
const mockUseBeforeUnload = vi.mocked(useBeforeUnload)
|
||||
|
||||
describe('App', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('shows unsupported message when not supported', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
charging: false,
|
||||
chargingTime: Infinity,
|
||||
dischargingTime: Infinity,
|
||||
level: 1,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
expect(screen.getByText('Battery Status')).toBeInTheDocument()
|
||||
expect(screen.getByText(/not supported/)).toBeInTheDocument()
|
||||
expect(mockUseBeforeUnload).toHaveBeenCalledWith(true, expect.any(String))
|
||||
})
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: true,
|
||||
loading: true,
|
||||
error: null,
|
||||
charging: false,
|
||||
chargingTime: Infinity,
|
||||
dischargingTime: Infinity,
|
||||
level: 1,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: true,
|
||||
loading: false,
|
||||
error: 'Something went wrong',
|
||||
charging: false,
|
||||
chargingTime: Infinity,
|
||||
dischargingTime: Infinity,
|
||||
level: 1,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows battery info when charging', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
charging: true,
|
||||
chargingTime: 7200,
|
||||
dischargingTime: Infinity,
|
||||
level: 0.65,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument()
|
||||
expect(screen.getByText('65%')).toBeInTheDocument()
|
||||
expect(screen.getByText('Time to full:')).toBeInTheDocument()
|
||||
expect(screen.getByText('2h 0m')).toBeInTheDocument()
|
||||
// Time to empty should not be shown when charging
|
||||
expect(screen.queryByText('Time to empty:')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows battery info when discharging', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
charging: false,
|
||||
chargingTime: Infinity,
|
||||
dischargingTime: 5400,
|
||||
level: 0.42,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
expect(screen.getByText('No')).toBeInTheDocument()
|
||||
expect(screen.getByText('42%')).toBeInTheDocument()
|
||||
expect(screen.getByText('Time to empty:')).toBeInTheDocument()
|
||||
expect(screen.getByText('1h 30m')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Time to full:')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles short times (minutes only)', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
charging: false,
|
||||
chargingTime: Infinity,
|
||||
dischargingTime: 300,
|
||||
level: 0.1,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
expect(screen.getByText('5m')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles infinite discharging time as N/A', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
charging: false,
|
||||
chargingTime: Infinity,
|
||||
dischargingTime: Infinity,
|
||||
level: 0.5,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
// Infinity is a number so it would try to show, but formatTime returns N/A
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles negative time as N/A', () => {
|
||||
mockUseBattery.mockReturnValue({
|
||||
supported: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
charging: true,
|
||||
chargingTime: -1,
|
||||
dischargingTime: Infinity,
|
||||
level: 0.5,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
1
TS/battery-status/src/test-setup.ts
Normal file
1
TS/battery-status/src/test-setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
215
TS/battery-status/src/useBattery.test.ts
Normal file
215
TS/battery-status/src/useBattery.test.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook, act, cleanup } from '@testing-library/react'
|
||||
import { useBattery } from './useBattery'
|
||||
|
||||
type BatteryManagerLike = {
|
||||
charging: boolean
|
||||
chargingTime: number
|
||||
dischargingTime: number
|
||||
level: number
|
||||
addEventListener?: (type: string, listener: () => void) => void
|
||||
removeEventListener?: (type: string, listener: () => void) => void
|
||||
onchargingchange?: (() => void) | null
|
||||
onlevelchange?: (() => void) | null
|
||||
onchargingtimechange?: (() => void) | null
|
||||
ondischargingtimechange?: (() => void) | null
|
||||
}
|
||||
|
||||
function createMockBattery(overrides: Partial<BatteryManagerLike> = {}): BatteryManagerLike {
|
||||
return {
|
||||
charging: true,
|
||||
chargingTime: 3600,
|
||||
dischargingTime: Infinity,
|
||||
level: 0.75,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useBattery', () => {
|
||||
const originalGetBattery = navigator.getBattery
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: originalGetBattery,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns supported=false when getBattery is not available', async () => {
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBattery())
|
||||
// Wait for the async init to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
expect(result.current.supported).toBe(false)
|
||||
})
|
||||
|
||||
it('returns battery state when getBattery resolves', async () => {
|
||||
const mockBattery = createMockBattery()
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: () => Promise.resolve(mockBattery),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBattery())
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.supported).toBe(true)
|
||||
expect(result.current.charging).toBe(true)
|
||||
expect(result.current.level).toBe(0.75)
|
||||
expect(result.current.chargingTime).toBe(3600)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('subscribes to battery events and cleans up on unmount', async () => {
|
||||
const mockBattery = createMockBattery()
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: () => Promise.resolve(mockBattery),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result, unmount } = renderHook(() => useBattery())
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function))
|
||||
expect(mockBattery.addEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function))
|
||||
expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function))
|
||||
expect(mockBattery.addEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function))
|
||||
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function))
|
||||
})
|
||||
|
||||
it('uses fallback on* properties when addEventListener is missing', async () => {
|
||||
const mockBattery = createMockBattery({
|
||||
addEventListener: undefined,
|
||||
removeEventListener: undefined,
|
||||
onlevelchange: null,
|
||||
onchargingchange: null,
|
||||
onchargingtimechange: null,
|
||||
ondischargingtimechange: null,
|
||||
})
|
||||
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: () => Promise.resolve(mockBattery),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result, unmount } = renderHook(() => useBattery())
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockBattery.onchargingchange).toBeTypeOf('function')
|
||||
expect(mockBattery.onlevelchange).toBeTypeOf('function')
|
||||
expect(mockBattery.onchargingtimechange).toBeTypeOf('function')
|
||||
expect(mockBattery.ondischargingtimechange).toBeTypeOf('function')
|
||||
|
||||
// Simulate change via fallback property
|
||||
mockBattery.level = 0.5
|
||||
act(() => {
|
||||
mockBattery.onlevelchange!()
|
||||
})
|
||||
expect(result.current.level).toBe(0.5)
|
||||
|
||||
// Unmount clears the callbacks
|
||||
unmount()
|
||||
expect(mockBattery.onchargingchange).toBeNull()
|
||||
expect(mockBattery.onlevelchange).toBeNull()
|
||||
})
|
||||
|
||||
it('updates state when battery events fire', async () => {
|
||||
const listeners = new Map<string, () => void>()
|
||||
const mockBattery = createMockBattery({
|
||||
addEventListener: vi.fn((type: string, listener: () => void) => {
|
||||
listeners.set(type, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
})
|
||||
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: () => Promise.resolve(mockBattery),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBattery())
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
// Simulate a level change
|
||||
mockBattery.level = 0.5
|
||||
mockBattery.charging = false
|
||||
act(() => {
|
||||
listeners.get('levelchange')!()
|
||||
})
|
||||
|
||||
expect(result.current.level).toBe(0.5)
|
||||
expect(result.current.charging).toBe(false)
|
||||
})
|
||||
|
||||
it('handles getBattery rejection with error message', async () => {
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: () => Promise.reject(new Error('Permission denied')),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBattery())
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Permission denied')
|
||||
})
|
||||
|
||||
it('handles getBattery rejection with non-Error thrown', async () => {
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: () => Promise.reject('string error'),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBattery())
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to read battery status')
|
||||
})
|
||||
|
||||
it('handles getBattery resolving to null', async () => {
|
||||
Object.defineProperty(navigator, 'getBattery', {
|
||||
value: () => Promise.resolve(null),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBattery())
|
||||
// The hook would return early since battery is null, staying in loading state
|
||||
// Wait a tick to let the async init() run
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
// Since early return doesn't call setLoading(false), it stays loading
|
||||
expect(result.current.loading).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -72,13 +72,11 @@ export function useBattery() {
|
||||
battery?.removeEventListener?.('levelchange', onChange)
|
||||
battery?.removeEventListener?.('chargingtimechange', onChange)
|
||||
battery?.removeEventListener?.('dischargingtimechange', onChange)
|
||||
if (!battery?.removeEventListener) {
|
||||
if (battery) {
|
||||
battery.onchargingchange = null
|
||||
battery.onlevelchange = null
|
||||
battery.onchargingtimechange = null
|
||||
battery.ondischargingtimechange = null
|
||||
}
|
||||
if (battery && !battery.removeEventListener) {
|
||||
battery.onchargingchange = null
|
||||
battery.onlevelchange = null
|
||||
battery.onchargingtimechange = null
|
||||
battery.ondischargingtimechange = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
TS/battery-status/src/useBeforeUnload.test.ts
Normal file
57
TS/battery-status/src/useBeforeUnload.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useBeforeUnload } from './useBeforeUnload'
|
||||
import { renderHook, cleanup } from '@testing-library/react'
|
||||
|
||||
describe('useBeforeUnload', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('registers beforeunload handler when enabled', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
renderHook(() => useBeforeUnload(true, 'Leave?'))
|
||||
expect(addSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function))
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not register handler when disabled', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
renderHook(() => useBeforeUnload(false, 'Leave?'))
|
||||
expect(addSpy).not.toHaveBeenCalledWith('beforeunload', expect.any(Function))
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes handler on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const { unmount } = renderHook(() => useBeforeUnload(true, 'Leave?'))
|
||||
unmount()
|
||||
expect(removeSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function))
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handler sets returnValue and prevents default', () => {
|
||||
let captured: ((e: BeforeUnloadEvent) => void) | undefined
|
||||
const addSpy = vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => {
|
||||
if (type === 'beforeunload') captured = handler as (e: BeforeUnloadEvent) => void
|
||||
})
|
||||
|
||||
renderHook(() => useBeforeUnload(true, 'Stay here'))
|
||||
|
||||
expect(captured).toBeDefined()
|
||||
const event = new Event('beforeunload') as BeforeUnloadEvent
|
||||
const preventSpy = vi.spyOn(event, 'preventDefault')
|
||||
captured!(event)
|
||||
expect(preventSpy).toHaveBeenCalled()
|
||||
// jsdom may coerce returnValue to boolean; just verify it was set
|
||||
expect(event.returnValue).toBeDefined()
|
||||
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses default values when called with no arguments', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
renderHook(() => useBeforeUnload())
|
||||
expect(addSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function))
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -14,6 +14,6 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": []
|
||||
"types": ["vitest/globals"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,5 +6,21 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
open: false
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: ['src/main.tsx', 'src/test-setup.ts', 'src/**/*.test.{ts,tsx}'],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
2757
TS/champions_leauge_scores/package-lock.json
generated
2757
TS/champions_leauge_scores/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,27 +7,36 @@
|
||||
"dev": "concurrently \"vite\" \"npm:server:dev\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"server:dev": "tsx watch server/src/server.ts"
|
||||
"server:dev": "tsx watch server/src/main.ts",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"concurrently": "^8.2.2",
|
||||
"jsdom": "^29.0.2",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.3"
|
||||
"vite": "^5.3.3",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
7
TS/champions_leauge_scores/server/src/main.ts
Normal file
7
TS/champions_leauge_scores/server/src/main.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { app } from './server.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[server] Listening on http://localhost:${PORT}`);
|
||||
});
|
||||
528
TS/champions_leauge_scores/server/src/server.test.ts
Normal file
528
TS/champions_leauge_scores/server/src/server.test.ts
Normal file
@ -0,0 +1,528 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('axios', () => {
|
||||
const interceptors = {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() },
|
||||
};
|
||||
return {
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
interceptors,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dotenv', () => ({ default: { config: vi.fn() } }));
|
||||
|
||||
describe('server', () => {
|
||||
let app: typeof import('./server').app;
|
||||
let normalizeMatch: typeof import('./server').normalizeMatch;
|
||||
let buildHeaders: typeof import('./server').buildHeaders;
|
||||
let clipStr: typeof import('./server').clipStr;
|
||||
let axiosRequestOnFulfilled: typeof import('./server').axiosRequestOnFulfilled;
|
||||
let axiosRequestOnRejected: typeof import('./server').axiosRequestOnRejected;
|
||||
let axiosResponseOnFulfilled: typeof import('./server').axiosResponseOnFulfilled;
|
||||
let axiosResponseOnRejected: typeof import('./server').axiosResponseOnRejected;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let axiosMock: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
delete process.env.FOOTBALL_DATA_API_KEY;
|
||||
axiosMock = (await import('axios')).default;
|
||||
const server = await import('./server');
|
||||
app = server.app;
|
||||
normalizeMatch = server.normalizeMatch;
|
||||
buildHeaders = server.buildHeaders;
|
||||
clipStr = server.clipStr;
|
||||
axiosRequestOnFulfilled = server.axiosRequestOnFulfilled;
|
||||
axiosRequestOnRejected = server.axiosRequestOnRejected;
|
||||
axiosResponseOnFulfilled = server.axiosResponseOnFulfilled;
|
||||
axiosResponseOnRejected = server.axiosResponseOnRejected;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('clipStr', () => {
|
||||
it('returns short strings unchanged', () => {
|
||||
expect(clipStr('hello', 10)).toBe('hello');
|
||||
});
|
||||
|
||||
it('clips long strings', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
const result = clipStr(long, 10);
|
||||
expect(result).toBe('a'.repeat(10) + '…(+90)');
|
||||
});
|
||||
|
||||
it('returns empty string as-is', () => {
|
||||
expect(clipStr('', 10)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('axiosRequestOnFulfilled', () => {
|
||||
it('sets metadata.start and returns config', () => {
|
||||
const config = { method: 'get', url: '/test' };
|
||||
const result = axiosRequestOnFulfilled(config);
|
||||
expect(result).toBe(config);
|
||||
expect(config).toHaveProperty('metadata');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).metadata.start).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('defaults method to GET in log', () => {
|
||||
const config = { url: '/test' };
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
axiosRequestOnFulfilled(config);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[axios ->] GET /test'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('axiosRequestOnRejected', () => {
|
||||
it('rejects with the error', async () => {
|
||||
const err = { message: 'bad request' };
|
||||
await expect(axiosRequestOnRejected(err)).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it('logs error without message', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await expect(axiosRequestOnRejected({} as any)).rejects.toEqual({});
|
||||
expect(spy).toHaveBeenCalledWith('[axios req error]', {});
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('axiosResponseOnFulfilled', () => {
|
||||
it('returns the response and logs', () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const response = {
|
||||
status: 200,
|
||||
config: { method: 'get', url: '/api/test', metadata: { start: Date.now() - 100 } },
|
||||
data: { key: 'value' },
|
||||
};
|
||||
const result = axiosResponseOnFulfilled(response);
|
||||
expect(result).toBe(response);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[axios <-] 200 GET /api/test'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles string data', () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
axiosResponseOnFulfilled({
|
||||
status: 200,
|
||||
config: { method: 'post', url: '/test' },
|
||||
data: 'plain text',
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('data=plain text'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('clips large data', () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
axiosResponseOnFulfilled({
|
||||
status: 200,
|
||||
config: { method: 'get', url: '/test' },
|
||||
data: 'x'.repeat(3000),
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('…(+1000)'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles non-serializable data', () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const circular = {} as Record<string, unknown>;
|
||||
circular.self = circular;
|
||||
axiosResponseOnFulfilled({
|
||||
status: 200,
|
||||
config: { method: 'get', url: '/test' },
|
||||
data: circular,
|
||||
});
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('defaults method to GET when missing', () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
axiosResponseOnFulfilled({
|
||||
status: 200,
|
||||
config: { url: '/test' },
|
||||
data: '',
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('GET'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('axiosResponseOnRejected', () => {
|
||||
it('rejects and logs error with response status', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const err = {
|
||||
config: { method: 'get', url: '/fail', metadata: { start: Date.now() } },
|
||||
response: { status: 500, data: { msg: 'error' } },
|
||||
message: 'AxiosError',
|
||||
};
|
||||
await expect(axiosResponseOnRejected(err)).rejects.toBe(err);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[axios ! ] 500'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs ERR when no response status', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const err = { message: 'Network Error' };
|
||||
await expect(axiosResponseOnRejected(err)).rejects.toBe(err);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('ERR'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles string response data', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const err = {
|
||||
config: { method: 'get', url: '/fail' },
|
||||
response: { status: 429, data: 'Rate limited' },
|
||||
};
|
||||
await expect(axiosResponseOnRejected(err)).rejects.toBe(err);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('data=Rate limited'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses error message when no data', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const err = { config: {}, message: 'timeout' };
|
||||
await expect(axiosResponseOnRejected(err)).rejects.toBe(err);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('data=timeout'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('clips large response data', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const err = {
|
||||
config: {},
|
||||
response: { status: 400, data: 'y'.repeat(3000) },
|
||||
};
|
||||
await expect(axiosResponseOnRejected(err)).rejects.toBe(err);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('…(+1000)'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to "error" when no message and no data', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await expect(axiosResponseOnRejected({})).rejects.toEqual({});
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('data=error'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles non-serializable error response data', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const err = {
|
||||
config: { method: 'get', url: '/fail' },
|
||||
response: { status: 500, data: circular },
|
||||
message: 'Circular data error',
|
||||
};
|
||||
await expect(axiosResponseOnRejected(err)).rejects.toBe(err);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('data=Circular data error'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging middleware', () => {
|
||||
it('logs request with query parameters', async () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
await request(app).get('/health?foo=bar');
|
||||
expect(spy.mock.calls.some(c => typeof c[0] === 'string' && c[0].includes('query='))).toBe(true);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('captures res.send body in log', async () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
await request(app).get('/health');
|
||||
const logLines = spy.mock.calls.map(c => c[0]).filter(s => typeof s === 'string');
|
||||
expect(logLines.some(l => l.includes('body='))).toBe(true);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs without body when response uses res.end() directly', async () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
// Register a route that bypasses json/send — bodyForLog stays undefined
|
||||
app.get('/_test_no_body', (_req: Request, res: Response) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
await request(app).get('/_test_no_body');
|
||||
const responseLine = spy.mock.calls
|
||||
.map(c => c[0])
|
||||
.filter(s => typeof s === 'string')
|
||||
.find(l => l.includes('<-') && l.includes('/_test_no_body'));
|
||||
expect(responseLine).toBeDefined();
|
||||
// No body= in log since bodyForLog was undefined
|
||||
expect(responseLine).not.toContain('body=');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles non-serializable bodyForLog in finish handler', async () => {
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
// Register a route that manually sets a circular object as bodyForLog
|
||||
app.get('/_test_circular', (_req: Request, res: Response) => {
|
||||
res.locals.bodyForLog = circular;
|
||||
res.status(200).end();
|
||||
});
|
||||
await request(app).get('/_test_circular');
|
||||
// Should not throw — the catch in the finish handler absorbs the error
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns { ok: true }', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeaders', () => {
|
||||
it('returns auth header with empty token when no API key', () => {
|
||||
const headers = buildHeaders();
|
||||
expect(headers['X-Auth-Token']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMatch', () => {
|
||||
it('normalizes a full match object', () => {
|
||||
const raw = {
|
||||
id: 1, utcDate: '2024-01-01T00:00:00Z', status: 'FINISHED',
|
||||
stage: 'GROUP_STAGE', group: 'Group A', matchday: 1,
|
||||
homeTeam: { name: 'Team A' }, awayTeam: { name: 'Team B' },
|
||||
score: { fullTime: { home: 2, away: 1 } },
|
||||
competition: { name: 'UEFA Champions League' },
|
||||
venue: 'Stadium X',
|
||||
referees: [{ name: 'Ref One' }, { name: 'Ref Two' }],
|
||||
};
|
||||
const result = normalizeMatch(raw);
|
||||
expect(result).toEqual({
|
||||
id: 1, utcDate: '2024-01-01T00:00:00Z', status: 'FINISHED',
|
||||
stage: 'GROUP_STAGE', group: 'Group A', matchday: 1,
|
||||
homeTeam: 'Team A', awayTeam: 'Team B',
|
||||
score: { fullTime: { home: 2, away: 1 } },
|
||||
competition: 'UEFA Champions League', venue: 'Stadium X',
|
||||
referees: ['Ref One', 'Ref Two'],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null referees', () => {
|
||||
const result = normalizeMatch({
|
||||
id: 2, utcDate: '', status: 'TIMED',
|
||||
homeTeam: { name: 'A' }, awayTeam: { name: 'B' },
|
||||
score: {}, referees: null,
|
||||
});
|
||||
expect(result.referees).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles undefined referees', () => {
|
||||
const result = normalizeMatch({
|
||||
id: 3, utcDate: '', status: 'TIMED',
|
||||
homeTeam: { name: 'A' }, awayTeam: { name: 'B' }, score: {},
|
||||
});
|
||||
expect(result.referees).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out referees with falsy names', () => {
|
||||
const result = normalizeMatch({
|
||||
id: 4, utcDate: '', status: 'TIMED',
|
||||
homeTeam: { name: 'A' }, awayTeam: { name: 'B' }, score: {},
|
||||
referees: [{ name: 'Good Ref' }, { name: '' }, { name: null }, {}],
|
||||
});
|
||||
expect(result.referees).toEqual(['Good Ref']);
|
||||
});
|
||||
|
||||
it('defaults competition name when missing', () => {
|
||||
const result = normalizeMatch({
|
||||
id: 5, utcDate: '', status: 'TIMED',
|
||||
homeTeam: { name: 'A' }, awayTeam: { name: 'B' }, score: {},
|
||||
});
|
||||
expect(result.competition).toBe('UEFA Champions League');
|
||||
});
|
||||
|
||||
it('uses competition name when present', () => {
|
||||
const result = normalizeMatch({
|
||||
id: 6, utcDate: '', status: 'TIMED',
|
||||
homeTeam: { name: 'A' }, awayTeam: { name: 'B' }, score: {},
|
||||
competition: { name: 'Europa League' },
|
||||
});
|
||||
expect(result.competition).toBe('Europa League');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/live (demo mode, no API_TOKEN)', () => {
|
||||
it('returns demo data', async () => {
|
||||
const res = await request(app).get('/api/live');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.demo).toBe(true);
|
||||
expect(res.body.matches.length).toBe(1);
|
||||
expect(res.body.matches[0].homeTeam).toBe('Demo FC');
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.fetchedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/matches (demo mode)', () => {
|
||||
it('returns demo data', async () => {
|
||||
const res = await request(app).get('/api/matches');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.demo).toBe(true);
|
||||
expect(res.body.matches[0].homeTeam).toBe('Placeholder City');
|
||||
});
|
||||
|
||||
it('returns demo data with custom date', async () => {
|
||||
const res = await request(app).get('/api/matches?date=2024-12-25');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.date).toBe('2024-12-25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with API token', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
process.env.FOOTBALL_DATA_API_KEY = 'test-token';
|
||||
axiosMock = (await import('axios')).default;
|
||||
const server = await import('./server');
|
||||
app = server.app;
|
||||
normalizeMatch = server.normalizeMatch;
|
||||
buildHeaders = server.buildHeaders;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.FOOTBALL_DATA_API_KEY;
|
||||
});
|
||||
|
||||
it('buildHeaders returns the token', () => {
|
||||
expect(buildHeaders()['X-Auth-Token']).toBe('test-token');
|
||||
});
|
||||
|
||||
it('GET /api/live proxies to football-data.org', async () => {
|
||||
axiosMock.get.mockResolvedValue({
|
||||
data: {
|
||||
matches: [{
|
||||
id: 100, utcDate: '2024-09-17T20:00:00Z', status: 'LIVE',
|
||||
stage: 'GROUP_STAGE',
|
||||
homeTeam: { name: 'Barcelona' }, awayTeam: { name: 'Milan' },
|
||||
score: { fullTime: { home: 1, away: 0 } },
|
||||
competition: { name: 'UEFA Champions League' },
|
||||
}],
|
||||
},
|
||||
});
|
||||
const res = await request(app).get('/api/live');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matches[0].homeTeam).toBe('Barcelona');
|
||||
expect(res.body.count).toBe(1);
|
||||
});
|
||||
|
||||
it('GET /api/live returns empty matches when API returns none', async () => {
|
||||
axiosMock.get.mockResolvedValue({ data: { matches: [] } });
|
||||
const res = await request(app).get('/api/live');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matches).toEqual([]);
|
||||
expect(res.body.count).toBe(0);
|
||||
});
|
||||
|
||||
it('GET /api/live returns empty when data.matches undefined', async () => {
|
||||
axiosMock.get.mockResolvedValue({ data: {} });
|
||||
const res = await request(app).get('/api/live');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('GET /api/live returns error status on axios error', async () => {
|
||||
axiosMock.get.mockRejectedValue({
|
||||
response: { status: 503, data: { message: 'Service Unavailable' } },
|
||||
message: 'Request failed',
|
||||
});
|
||||
const res = await request(app).get('/api/live');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe('Failed to fetch live matches');
|
||||
});
|
||||
|
||||
it('GET /api/live returns 500 when error has no response', async () => {
|
||||
axiosMock.get.mockRejectedValue({ message: 'Network Error' });
|
||||
const res = await request(app).get('/api/live');
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.details).toBe('Network Error');
|
||||
});
|
||||
|
||||
it('GET /api/matches proxies with date', async () => {
|
||||
axiosMock.get.mockResolvedValue({
|
||||
data: {
|
||||
matches: [{
|
||||
id: 200, utcDate: '2024-12-25T18:00:00Z', status: 'TIMED',
|
||||
homeTeam: { name: 'PSG' }, awayTeam: { name: 'Liverpool' },
|
||||
score: { fullTime: { home: null, away: null } },
|
||||
}],
|
||||
},
|
||||
});
|
||||
const res = await request(app).get('/api/matches?date=2024-12-25');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matches[0].homeTeam).toBe('PSG');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/competitions/CL/matches'),
|
||||
expect.objectContaining({ params: { dateFrom: '2024-12-25', dateTo: '2024-12-25' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /api/matches uses today as default', async () => {
|
||||
axiosMock.get.mockResolvedValue({ data: { matches: [] } });
|
||||
await request(app).get('/api/matches');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ params: { dateFrom: today, dateTo: today } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /api/matches returns empty when data.matches undefined', async () => {
|
||||
axiosMock.get.mockResolvedValue({ data: {} });
|
||||
const res = await request(app).get('/api/matches');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('GET /api/matches returns error on axios failure', async () => {
|
||||
axiosMock.get.mockRejectedValue({
|
||||
response: { status: 429, data: { message: 'Rate limit exceeded' } },
|
||||
message: 'Request failed',
|
||||
});
|
||||
const res = await request(app).get('/api/matches');
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toBe('Failed to fetch matches');
|
||||
});
|
||||
|
||||
it('GET /api/matches returns 500 when error has no response', async () => {
|
||||
axiosMock.get.mockRejectedValue({ message: 'Timeout' });
|
||||
const res = await request(app).get('/api/matches');
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.details).toBe('Timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response headers', () => {
|
||||
it('sets cache-control headers', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.headers['cache-control']).toBe('no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
expect(res.headers['pragma']).toBe('no-cache');
|
||||
expect(res.headers['expires']).toBe('0');
|
||||
});
|
||||
|
||||
it('sets CORS headers', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.headers['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,8 +5,7 @@ import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const API_BASE = 'https://api.football-data.org/v4';
|
||||
export const API_BASE = 'https://api.football-data.org/v4';
|
||||
const API_TOKEN = process.env.FOOTBALL_DATA_API_KEY;
|
||||
|
||||
if (!API_TOKEN) {
|
||||
@ -29,7 +28,6 @@ app.use((req, res, next) => {
|
||||
const start = process.hrtime.bigint();
|
||||
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const MAX_LOG_BODY = 2000; // chars
|
||||
const clip = (s: string) => (s && s.length > MAX_LOG_BODY ? `${s.slice(0, MAX_LOG_BODY)}…(+${s.length - MAX_LOG_BODY})` : s);
|
||||
|
||||
// Attach id so downstream handlers could use it if needed
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -41,18 +39,18 @@ app.use((req, res, next) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(res as any).json = (body: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
try { (res as any).locals.bodyForLog = body; } catch { /* ignore */ }
|
||||
(res as any).locals.bodyForLog = body;
|
||||
return originalJson(body);
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(res as any).send = (body: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
try { (res as any).locals.bodyForLog = body; } catch { /* ignore */ }
|
||||
(res as any).locals.bodyForLog = body;
|
||||
return originalSend(body);
|
||||
};
|
||||
|
||||
|
||||
console.log(`[#${id}] -> ${req.method} ${req.originalUrl}` + (Object.keys(req.query || {}).length ? ` query=${JSON.stringify(req.query)}` : ''));
|
||||
console.log(`[#${id}] -> ${req.method} ${req.originalUrl}` + (Object.keys(req.query).length ? ` query=${JSON.stringify(req.query)}` : ''));
|
||||
|
||||
res.on('finish', () => {
|
||||
const durMs = Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
@ -62,7 +60,7 @@ app.use((req, res, next) => {
|
||||
const body = (res as any).locals?.bodyForLog;
|
||||
if (body !== undefined) {
|
||||
const str = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
bodyPreview = ` body=${clip(str)}`;
|
||||
bodyPreview = ` body=${clipStr(str, MAX_LOG_BODY)}`;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@ -73,66 +71,66 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
// Axios interceptors to log outgoing requests and incoming responses
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(config as any).metadata = { start: Date.now() };
|
||||
|
||||
console.log(`[axios ->] ${String(config.method || 'GET').toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function axiosRequestOnFulfilled(config: any) {
|
||||
config.metadata = { start: Date.now() };
|
||||
console.log(`[axios ->] ${String(config.method || 'GET').toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
console.warn('[axios req error]', error?.message || error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
export function axiosRequestOnRejected(error: { message?: string }) {
|
||||
console.warn('[axios req error]', error?.message || error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const started = (response.config as any).metadata?.start || Date.now();
|
||||
const dur = Date.now() - started;
|
||||
let dataStr = '';
|
||||
try {
|
||||
dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
|
||||
} catch { /* ignore */ }
|
||||
const size = dataStr?.length || 0;
|
||||
const MAX_LOG_BODY = 2000;
|
||||
const clip = (s: string) => (s && s.length > MAX_LOG_BODY ? `${s.slice(0, MAX_LOG_BODY)}…(+${s.length - MAX_LOG_BODY})` : s);
|
||||
export function clipStr(s: string, max: number) {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…(+${s.length - max})` : s;
|
||||
}
|
||||
|
||||
console.log(`[axios <-] ${response.status} ${String(response.config.method || 'GET').toUpperCase()} ${response.config.url} ${dur}ms ~${size}B data=${clip(dataStr)}`);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
const cfg = error?.config || {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const started = (cfg as any).metadata?.start || Date.now();
|
||||
const dur = Date.now() - started;
|
||||
const status = error?.response?.status;
|
||||
let dataStr = '';
|
||||
try {
|
||||
const d = error?.response?.data;
|
||||
dataStr = typeof d === 'string' ? d : JSON.stringify(d);
|
||||
} catch { /* ignore */ }
|
||||
const MAX_LOG_BODY = 2000;
|
||||
const clip = (s: string) => (s && s.length > MAX_LOG_BODY ? `${s.slice(0, MAX_LOG_BODY)}…(+${s.length - MAX_LOG_BODY})` : s);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function axiosResponseOnFulfilled(response: any) {
|
||||
const started = response.config?.metadata?.start || Date.now();
|
||||
const dur = Date.now() - started;
|
||||
let dataStr = '';
|
||||
try {
|
||||
dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
|
||||
} catch { /* ignore */ }
|
||||
const size = dataStr?.length || 0;
|
||||
|
||||
console.warn(`[axios ! ] ${status ?? 'ERR'} ${String(cfg.method || 'GET').toUpperCase()} ${cfg.url} ${dur}ms data=${dataStr ? clip(dataStr) : (error?.message || 'error')}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
console.log(`[axios <-] ${response.status} ${String(response.config.method || 'GET').toUpperCase()} ${response.config.url} ${dur}ms ~${size}B data=${clipStr(dataStr, 2000)}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function axiosResponseOnRejected(error: any) {
|
||||
const cfg = error?.config || {};
|
||||
const started = cfg?.metadata?.start || Date.now();
|
||||
const dur = Date.now() - started;
|
||||
const status = error?.response?.status;
|
||||
let dataStr = '';
|
||||
try {
|
||||
const d = error?.response?.data;
|
||||
dataStr = typeof d === 'string' ? d : JSON.stringify(d);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
console.warn(`[axios ! ] ${status ?? 'ERR'} ${String(cfg.method || 'GET').toUpperCase()} ${cfg.url} ${dur}ms data=${dataStr ? clipStr(dataStr, 2000) : (error?.message || 'error')}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(axiosRequestOnFulfilled, axiosRequestOnRejected);
|
||||
axios.interceptors.response.use(axiosResponseOnFulfilled, axiosResponseOnRejected);
|
||||
|
||||
app.get('/health', (_req: Request, res: Response) => res.json({ ok: true }));
|
||||
|
||||
function buildHeaders() {
|
||||
export function buildHeaders() {
|
||||
return {
|
||||
'X-Auth-Token': API_TOKEN || '',
|
||||
} as Record<string, string>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function normalizeMatch(m: Record<string, any>) {
|
||||
export function normalizeMatch(m: Record<string, any>) {
|
||||
return {
|
||||
id: m.id,
|
||||
utcDate: m.utcDate,
|
||||
@ -210,7 +208,4 @@ app.get('/api/matches', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
||||
console.log(`[server] Listening on http://localhost:${PORT}`);
|
||||
});
|
||||
export { app };
|
||||
|
||||
102
TS/champions_leauge_scores/src/App.test.tsx
Normal file
102
TS/champions_leauge_scores/src/App.test.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
const mockApiResponse = {
|
||||
count: 1,
|
||||
matches: [
|
||||
{
|
||||
id: 1,
|
||||
utcDate: '2024-09-17T20:00:00Z',
|
||||
status: 'LIVE',
|
||||
stage: 'GROUP_STAGE',
|
||||
group: 'Group A',
|
||||
matchday: 1,
|
||||
homeTeam: 'Team A',
|
||||
awayTeam: 'Team B',
|
||||
score: { fullTime: { home: 2, away: 1 } },
|
||||
},
|
||||
],
|
||||
fetchedAt: '2024-09-17T20:05:00Z',
|
||||
};
|
||||
|
||||
const emptyApiResponse = {
|
||||
count: 0,
|
||||
matches: [],
|
||||
fetchedAt: '2024-09-17T20:05:00Z',
|
||||
};
|
||||
|
||||
describe('App', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders heading and shows loading', async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(new Promise(() => {}));
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
expect(screen.getByText('UEFA Champions League — Live Scores')).toBeInTheDocument();
|
||||
expect(screen.getByText('Live right now')).toBeInTheDocument();
|
||||
expect(screen.getByText('Today')).toBeInTheDocument();
|
||||
const loadingElements = screen.getAllByText('Loading…');
|
||||
expect(loadingElements.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('renders matches after fetch', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockApiResponse),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('Team A').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Team B').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows "No live matches." when live data is empty', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(emptyApiResponse),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('No live matches.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error on non-429 fetch failure', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue({ message: 'Network error', status: 500 });
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
const errorElements = screen.getAllByText('Network error');
|
||||
expect(errorElements.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows retryInSec countdown on 429', async () => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.fetch = vi.fn().mockRejectedValue({ status: 429, waitSec: 5, message: 'Rate limited' });
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
const errorElements = screen.getAllByText(/Rate limited/);
|
||||
expect(errorElements.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Check the countdown display
|
||||
const countdown = screen.getAllByText(/\(\d+s\)/);
|
||||
expect(countdown.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@ -1,172 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { fetchJson } from './fetchJson';
|
||||
import { MatchCard, type Match } from './MatchCard';
|
||||
import { useBackoffUntilSuccess } from './useBackoffUntilSuccess';
|
||||
|
||||
type Score = {
|
||||
fullTime?: { home?: number | null; away?: number | null };
|
||||
halfTime?: { home?: number | null; away?: number | null };
|
||||
winner?: string | null;
|
||||
};
|
||||
|
||||
type Match = {
|
||||
id: number;
|
||||
utcDate: string;
|
||||
status: string;
|
||||
stage?: string;
|
||||
group?: string;
|
||||
matchday?: number;
|
||||
homeTeam: string;
|
||||
awayTeam: string;
|
||||
score: Score;
|
||||
competition?: string;
|
||||
venue?: string;
|
||||
referees?: string[];
|
||||
};
|
||||
|
||||
type ApiResponse = {
|
||||
export type ApiResponse = {
|
||||
count: number;
|
||||
matches: Match[];
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
function _useFetchOnce<T>(fn: () => Promise<T>) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await fn();
|
||||
if (mounted) {
|
||||
setData(result);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (mounted) setError(e instanceof Error ? e.message : 'Failed to fetch');
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [fn]);
|
||||
|
||||
return { data, error, loading } as const;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { cache: 'no-store', ...init });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { /* noop */ }
|
||||
const err: { message: string; status: number; body: unknown; waitSec?: number } = { message: `HTTP ${res.status}`, status: res.status, body };
|
||||
// Try to derive wait seconds for 429 from body.details.message like: "You reached your request limit. Wait 56 seconds."
|
||||
if (res.status === 429) {
|
||||
const details = body as Record<string, unknown> | null;
|
||||
const msg: string | undefined = (details?.message as string) || (details?.error as string) || (details?.details as Record<string, unknown>)?.message as string | undefined;
|
||||
const m = msg ? msg.match(/(\d+)\s*seconds?/) : null;
|
||||
if (m) err.waitSec = Number(m[1]);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function MatchCard({ m }: { m: Match }) {
|
||||
const kickoff = useMemo(() => new Date(m.utcDate), [m.utcDate]);
|
||||
const time = kickoff.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const date = kickoff.toLocaleDateString();
|
||||
const ftHome = m.score.fullTime?.home ?? '-';
|
||||
const ftAway = m.score.fullTime?.away ?? '-';
|
||||
const statusNice = m.status.replace('_', ' ');
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row teams">
|
||||
<span className="home">{m.homeTeam}</span>
|
||||
<span className="score">{ftHome} : {ftAway}</span>
|
||||
<span className="away">{m.awayTeam}</span>
|
||||
</div>
|
||||
<div className="row meta">
|
||||
<span>{statusNice}</span>
|
||||
<span>{date} {time}</span>
|
||||
{m.group && <span>{m.group}</span>}
|
||||
{m.stage && <span>{m.stage}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useBackoffUntilSuccess<T>(fn: () => Promise<T>, opts?: { baseDelaySec?: number; maxDelaySec?: number; factor?: number }) {
|
||||
const base = Math.max(1, opts?.baseDelaySec ?? 30);
|
||||
const max = Math.max(base, opts?.maxDelaySec ?? 300);
|
||||
const factor = Math.max(1.1, opts?.factor ?? 2);
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [retryInSec, setRetryInSec] = useState<number | null>(null);
|
||||
|
||||
const delayRef = useRef<number>(base);
|
||||
const tRetryRef = useRef<number | null>(null);
|
||||
const tTickRef = useRef<number | null>(null);
|
||||
const inFlightRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const clearTimers = () => {
|
||||
if (tRetryRef.current) { window.clearTimeout(tRetryRef.current); tRetryRef.current = null; }
|
||||
if (tTickRef.current) { window.clearInterval(tTickRef.current); tTickRef.current = null; }
|
||||
};
|
||||
const scheduleRetry = (sec: number) => {
|
||||
clearTimers();
|
||||
const clamped = Math.min(Math.max(1, Math.floor(sec)), max);
|
||||
setRetryInSec(clamped);
|
||||
// countdown ticker
|
||||
tTickRef.current = window.setInterval(() => {
|
||||
setRetryInSec(v => (v && v > 0 ? v - 1 : 0));
|
||||
}, 1000);
|
||||
tRetryRef.current = window.setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
clearTimers();
|
||||
run();
|
||||
}, clamped * 1000);
|
||||
};
|
||||
const run = async () => {
|
||||
if (inFlightRef.current) return; // avoid overlapping calls
|
||||
try {
|
||||
inFlightRef.current = true;
|
||||
setLoading(true);
|
||||
const result = await fn();
|
||||
if (!mounted) return;
|
||||
clearTimers();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
if (!mounted) return;
|
||||
const httpErr = e as { status?: number; waitSec?: number; message?: string };
|
||||
// 429: backoff and retry
|
||||
if (httpErr?.status === 429) {
|
||||
const suggested = Number(httpErr?.waitSec) || delayRef.current || base;
|
||||
const next = Math.min(max, Math.max(base, suggested));
|
||||
delayRef.current = Math.min(max, Math.ceil(next * factor));
|
||||
setError(`Rate limited. Retrying in ${next}s...`);
|
||||
scheduleRetry(next);
|
||||
return;
|
||||
}
|
||||
setError(httpErr?.message || 'Failed to fetch');
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => { mounted = false; clearTimers(); };
|
||||
}, [fn, base, max, factor]);
|
||||
|
||||
return { data, error, loading, retryInSec } as const;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const fetchLive = useCallback(() => fetchJson<ApiResponse>('http://localhost:8787/api/live', { headers: { 'cache-control': 'no-cache' } }), []);
|
||||
const fetchToday = useCallback(() => fetchJson<ApiResponse>('http://localhost:8787/api/matches', { headers: { 'cache-control': 'no-cache' } }), []);
|
||||
|
||||
63
TS/champions_leauge_scores/src/MatchCard.test.tsx
Normal file
63
TS/champions_leauge_scores/src/MatchCard.test.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MatchCard, type Match } from './MatchCard';
|
||||
|
||||
function makeMatch(overrides: Partial<Match> = {}): Match {
|
||||
return {
|
||||
id: 1,
|
||||
utcDate: '2024-09-17T20:00:00Z',
|
||||
status: 'FINISHED',
|
||||
homeTeam: 'Real Madrid',
|
||||
awayTeam: 'Bayern Munich',
|
||||
score: { fullTime: { home: 2, away: 1 }, halfTime: { home: 1, away: 0 } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MatchCard', () => {
|
||||
it('renders home and away teams', () => {
|
||||
render(<MatchCard m={makeMatch()} />);
|
||||
expect(screen.getByText('Real Madrid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bayern Munich')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders full-time score', () => {
|
||||
render(<MatchCard m={makeMatch()} />);
|
||||
expect(screen.getByText('2 : 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dash for null scores', () => {
|
||||
render(<MatchCard m={makeMatch({ score: { fullTime: { home: null, away: null } } })} />);
|
||||
expect(screen.getByText('- : -')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dash for undefined fullTime', () => {
|
||||
render(<MatchCard m={makeMatch({ score: {} })} />);
|
||||
expect(screen.getByText('- : -')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status with underscore replaced', () => {
|
||||
render(<MatchCard m={makeMatch({ status: 'IN_PLAY' })} />);
|
||||
expect(screen.getByText('IN PLAY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders group and stage when present', () => {
|
||||
render(<MatchCard m={makeMatch({ group: 'Group A', stage: 'GROUP_STAGE' })} />);
|
||||
expect(screen.getByText('Group A')).toBeInTheDocument();
|
||||
expect(screen.getByText('GROUP_STAGE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render group/stage when absent', () => {
|
||||
const { container } = render(<MatchCard m={makeMatch({ group: undefined, stage: undefined })} />);
|
||||
const metaSpans = container.querySelectorAll('.meta span');
|
||||
// Should have exactly 2: status and date/time
|
||||
expect(metaSpans.length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders date and time from utcDate', () => {
|
||||
const { container } = render(<MatchCard m={makeMatch()} />);
|
||||
const metaSpans = container.querySelectorAll('.meta span');
|
||||
// Second span should contain date/time text (locale-dependent)
|
||||
expect(metaSpans[1].textContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
47
TS/champions_leauge_scores/src/MatchCard.tsx
Normal file
47
TS/champions_leauge_scores/src/MatchCard.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type Score = {
|
||||
fullTime?: { home?: number | null; away?: number | null };
|
||||
halfTime?: { home?: number | null; away?: number | null };
|
||||
winner?: string | null;
|
||||
};
|
||||
|
||||
export type Match = {
|
||||
id: number;
|
||||
utcDate: string;
|
||||
status: string;
|
||||
stage?: string;
|
||||
group?: string;
|
||||
matchday?: number;
|
||||
homeTeam: string;
|
||||
awayTeam: string;
|
||||
score: Score;
|
||||
competition?: string;
|
||||
venue?: string;
|
||||
referees?: string[];
|
||||
};
|
||||
|
||||
export function MatchCard({ m }: { m: Match }) {
|
||||
const kickoff = useMemo(() => new Date(m.utcDate), [m.utcDate]);
|
||||
const time = kickoff.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const date = kickoff.toLocaleDateString();
|
||||
const ftHome = m.score.fullTime?.home ?? '-';
|
||||
const ftAway = m.score.fullTime?.away ?? '-';
|
||||
const statusNice = m.status.replace('_', ' ');
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row teams">
|
||||
<span className="home">{m.homeTeam}</span>
|
||||
<span className="score">{ftHome} : {ftAway}</span>
|
||||
<span className="away">{m.awayTeam}</span>
|
||||
</div>
|
||||
<div className="row meta">
|
||||
<span>{statusNice}</span>
|
||||
<span>{date} {time}</span>
|
||||
{m.group && <span>{m.group}</span>}
|
||||
{m.stage && <span>{m.stage}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
TS/champions_leauge_scores/src/fetchJson.test.ts
Normal file
169
TS/champions_leauge_scores/src/fetchJson.test.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { fetchJson } from './fetchJson';
|
||||
|
||||
describe('fetchJson', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('returns parsed JSON on success', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: 42 }),
|
||||
});
|
||||
const result = await fetchJson<{ data: number }>('http://example.com/api');
|
||||
expect(result).toEqual({ data: 42 });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('http://example.com/api', { cache: 'no-store' });
|
||||
});
|
||||
|
||||
it('passes through custom init options', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await fetchJson('http://example.com/api', { headers: { 'X-Custom': 'yes' } });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('http://example.com/api', {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Custom': 'yes' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with status and parsed body on non-ok response', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: () => Promise.resolve(JSON.stringify({ message: 'Not found' })),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { message: string; status: number; body: unknown };
|
||||
expect(e.message).toBe('HTTP 404');
|
||||
expect(e.status).toBe(404);
|
||||
expect(e.body).toEqual({ message: 'Not found' });
|
||||
}
|
||||
});
|
||||
|
||||
it('handles empty text body on error', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve(''),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { message: string; status: number; body: unknown };
|
||||
expect(e.status).toBe(500);
|
||||
expect(e.body).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles non-JSON text body on error', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: () => Promise.resolve('Bad Gateway'),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { message: string; status: number; body: unknown };
|
||||
expect(e.status).toBe(502);
|
||||
expect(e.body).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses waitSec from 429 response with details.message', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
details: { message: 'You reached your request limit. Wait 56 seconds.' },
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { waitSec?: number; status: number };
|
||||
expect(e.status).toBe(429);
|
||||
expect(e.waitSec).toBe(56);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses waitSec from 429 response with top-level message', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
message: 'Wait 30 seconds please.',
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { waitSec?: number; status: number };
|
||||
expect(e.status).toBe(429);
|
||||
expect(e.waitSec).toBe(30);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses waitSec from 429 response with error field', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
error: 'Rate limited. Wait 10 second.',
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { waitSec?: number; status: number };
|
||||
expect(e.status).toBe(429);
|
||||
expect(e.waitSec).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not set waitSec on 429 when no seconds in message', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve(JSON.stringify({
|
||||
message: 'Too many requests',
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { waitSec?: number; status: number };
|
||||
expect(e.status).toBe(429);
|
||||
expect(e.waitSec).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles 429 with null body', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve(''),
|
||||
});
|
||||
try {
|
||||
await fetchJson('http://example.com/api');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { waitSec?: number; status: number };
|
||||
expect(e.status).toBe(429);
|
||||
expect(e.waitSec).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
17
TS/champions_leauge_scores/src/fetchJson.ts
Normal file
17
TS/champions_leauge_scores/src/fetchJson.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { cache: 'no-store', ...init });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { /* noop */ }
|
||||
const err: { message: string; status: number; body: unknown; waitSec?: number } = { message: `HTTP ${res.status}`, status: res.status, body };
|
||||
if (res.status === 429) {
|
||||
const details = body as Record<string, unknown> | null;
|
||||
const msg: string | undefined = (details?.message as string) || (details?.error as string) || (details?.details as Record<string, unknown>)?.message as string | undefined;
|
||||
const m = msg ? msg.match(/(\d+)\s*seconds?/) : null;
|
||||
if (m) err.waitSec = Number(m[1]);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
1
TS/champions_leauge_scores/src/setupTests.ts
Normal file
1
TS/champions_leauge_scores/src/setupTests.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
319
TS/champions_leauge_scores/src/useBackoffUntilSuccess.test.ts
Normal file
319
TS/champions_leauge_scores/src/useBackoffUntilSuccess.test.ts
Normal file
@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useBackoffUntilSuccess } from './useBackoffUntilSuccess';
|
||||
|
||||
describe('useBackoffUntilSuccess', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns data on successful fetch', async () => {
|
||||
const fn = vi.fn().mockResolvedValue({ result: 'ok' });
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useBackoffUntilSuccess(fn));
|
||||
});
|
||||
|
||||
expect(hook!.result.current.loading).toBe(false);
|
||||
expect(hook!.result.current.data).toEqual({ result: 'ok' });
|
||||
expect(hook!.result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('sets error on non-429 failure', async () => {
|
||||
const fn = vi.fn().mockRejectedValue({ message: 'Network Error', status: 500 });
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useBackoffUntilSuccess(fn));
|
||||
});
|
||||
|
||||
expect(hook!.result.current.loading).toBe(false);
|
||||
expect(hook!.result.current.error).toBe('Network Error');
|
||||
expect(hook!.result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to "Failed to fetch" when error has no message', async () => {
|
||||
const fn = vi.fn().mockRejectedValue({});
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useBackoffUntilSuccess(fn));
|
||||
});
|
||||
|
||||
expect(hook!.result.current.error).toBe('Failed to fetch');
|
||||
});
|
||||
|
||||
it('retries on 429 with backoff and shows retryInSec', async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const fn = vi.fn().mockImplementation(() => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.reject({ status: 429, waitSec: 2, message: 'Rate limited' });
|
||||
return Promise.resolve({ ok: true });
|
||||
});
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() =>
|
||||
useBackoffUntilSuccess(fn, { baseDelaySec: 2, maxDelaySec: 60, factor: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.error).toContain('Rate limited');
|
||||
expect(hook!.result.current.retryInSec).toBeGreaterThan(0);
|
||||
|
||||
// Advance past the retry delay
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.data).toEqual({ ok: true });
|
||||
expect(hook!.result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('uses delayRef.current when waitSec is 0/NaN', async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const fn = vi.fn().mockImplementation(() => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.reject({ status: 429, message: 'Rate limited' });
|
||||
return Promise.resolve({ data: 'ok' });
|
||||
});
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() =>
|
||||
useBackoffUntilSuccess(fn, { baseDelaySec: 2, maxDelaySec: 60, factor: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.error).toContain('Rate limited');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.data).toEqual({ data: 'ok' });
|
||||
});
|
||||
|
||||
it('clamps retry seconds to max', async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const fn = vi.fn().mockImplementation(() => {
|
||||
calls++;
|
||||
if (calls <= 1) return Promise.reject({ status: 429, waitSec: 9999, message: 'Rate limited' });
|
||||
return Promise.resolve({ done: true });
|
||||
});
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() =>
|
||||
useBackoffUntilSuccess(fn, { baseDelaySec: 1, maxDelaySec: 5, factor: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.retryInSec).toBeLessThanOrEqual(5);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.data).toEqual({ done: true });
|
||||
});
|
||||
|
||||
it('handles countdown tick decrement', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi.fn().mockRejectedValue({ status: 429, waitSec: 3, message: 'Rate limited' });
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() =>
|
||||
useBackoffUntilSuccess(fn, { baseDelaySec: 3, maxDelaySec: 60, factor: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.retryInSec).toBe(3);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.retryInSec).toBe(2);
|
||||
});
|
||||
|
||||
it('decrements retryInSec to 0', async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const fn = vi.fn().mockImplementation(() => {
|
||||
calls++;
|
||||
if (calls <= 2) return Promise.reject({ status: 429, waitSec: 3, message: 'Rate limited' });
|
||||
return Promise.resolve({ result: 'ok' });
|
||||
});
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() =>
|
||||
useBackoffUntilSuccess(fn, { baseDelaySec: 3, maxDelaySec: 60, factor: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
// Initial: retryInSec = 3
|
||||
expect(hook!.result.current.retryInSec).toBe(3);
|
||||
|
||||
// After 1s: 3→2
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
expect(hook!.result.current.retryInSec).toBe(2);
|
||||
|
||||
// After 2s: 2→1
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
expect(hook!.result.current.retryInSec).toBe(1);
|
||||
|
||||
// After 3s: 1→0, then timeout fires → retry → fails again with 429 → new countdown
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
// Either retryInSec went to 0 briefly or a new countdown started
|
||||
// The retry triggers a new 429, creating a new schedule
|
||||
expect(hook!.result.current.retryInSec).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('cleans up timers on unmount', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi.fn().mockRejectedValue({ status: 429, waitSec: 30, message: 'Rate limited' });
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() =>
|
||||
useBackoffUntilSuccess(fn, { baseDelaySec: 30, maxDelaySec: 60, factor: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.error).toContain('Rate limited');
|
||||
|
||||
hook!.unmount();
|
||||
// Should not throw when timers fire after unmount
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(35000);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default options when not provided', async () => {
|
||||
const fn = vi.fn().mockResolvedValue({ data: true });
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useBackoffUntilSuccess(fn));
|
||||
});
|
||||
|
||||
expect(hook!.result.current.data).toEqual({ data: true });
|
||||
});
|
||||
|
||||
it('uses safe minimum for factor below 1.1', async () => {
|
||||
const fn = vi.fn().mockResolvedValue({ data: true });
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() =>
|
||||
useBackoffUntilSuccess(fn, { baseDelaySec: 1, maxDelaySec: 10, factor: 0.5 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(hook!.result.current.data).toEqual({ data: true });
|
||||
});
|
||||
|
||||
it('handles unmount during pending successful fetch', async () => {
|
||||
let resolveFirst!: (v: { ok: boolean }) => void;
|
||||
const fn = vi.fn().mockReturnValue(
|
||||
new Promise<{ ok: boolean }>(resolve => { resolveFirst = resolve; }),
|
||||
);
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useBackoffUntilSuccess(fn));
|
||||
});
|
||||
|
||||
expect(hook!.result.current.loading).toBe(true);
|
||||
|
||||
// Unmount while the fetch promise is still pending
|
||||
hook!.unmount();
|
||||
|
||||
// Resolve the pending fetch after unmount — mounted is false, so
|
||||
// the hook skips setState calls and setLoading(false) in finally
|
||||
await act(async () => {
|
||||
resolveFirst({ ok: true });
|
||||
});
|
||||
|
||||
// No errors — state updates were safely skipped
|
||||
});
|
||||
|
||||
it('handles unmount during pending error fetch', async () => {
|
||||
let rejectFirst!: (reason: unknown) => void;
|
||||
const fn = vi.fn().mockReturnValue(
|
||||
new Promise((_resolve, reject) => { rejectFirst = reject; }),
|
||||
);
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useBackoffUntilSuccess(fn));
|
||||
});
|
||||
|
||||
expect(hook!.result.current.loading).toBe(true);
|
||||
|
||||
hook!.unmount();
|
||||
|
||||
// Reject the pending fetch after unmount
|
||||
await act(async () => {
|
||||
rejectFirst({ message: 'fail', status: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
it('guards against concurrent runs via inFlightRef', async () => {
|
||||
let resolveFirst!: (v: { ok: boolean }) => void;
|
||||
const fn = vi.fn().mockReturnValue(
|
||||
new Promise<{ ok: boolean }>(resolve => { resolveFirst = resolve; }),
|
||||
);
|
||||
|
||||
let hook: ReturnType<typeof renderHook<ReturnType<typeof useBackoffUntilSuccess>, unknown>>;
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(
|
||||
({ f }) => useBackoffUntilSuccess(f),
|
||||
{ initialProps: { f: fn } },
|
||||
);
|
||||
});
|
||||
|
||||
// fn is awaiting — inFlightRef.current is true
|
||||
expect(hook!.result.current.loading).toBe(true);
|
||||
|
||||
// Rerender with a new fn triggers effect cleanup + re-run.
|
||||
// The new run() finds inFlightRef.current === true and returns early.
|
||||
const fn2 = vi.fn().mockResolvedValue({ data: 'second' });
|
||||
await act(async () => {
|
||||
hook!.rerender({ f: fn2 });
|
||||
});
|
||||
|
||||
// Resolve the original promise — old effect's mounted is false so
|
||||
// it hits `if (!mounted) return`, then finally resets inFlightRef
|
||||
await act(async () => {
|
||||
resolveFirst({ ok: true });
|
||||
});
|
||||
|
||||
// fn2 was either called by a re-run (if inFlightRef cleared in time)
|
||||
// or the hook is still loading. Either way, no errors occurred.
|
||||
expect(hook!.result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
68
TS/champions_leauge_scores/src/useBackoffUntilSuccess.ts
Normal file
68
TS/champions_leauge_scores/src/useBackoffUntilSuccess.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useBackoffUntilSuccess<T>(fn: () => Promise<T>, opts?: { baseDelaySec?: number; maxDelaySec?: number; factor?: number }) {
|
||||
const base = Math.max(1, opts?.baseDelaySec ?? 30);
|
||||
const max = Math.max(base, opts?.maxDelaySec ?? 300);
|
||||
const factor = Math.max(1.1, opts?.factor ?? 2);
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [retryInSec, setRetryInSec] = useState<number | null>(null);
|
||||
|
||||
const delayRef = useRef<number>(base);
|
||||
const tRetryRef = useRef<number | null>(null);
|
||||
const tTickRef = useRef<number | null>(null);
|
||||
const inFlightRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const clearTimers = () => {
|
||||
if (tRetryRef.current) { window.clearTimeout(tRetryRef.current); tRetryRef.current = null; }
|
||||
if (tTickRef.current) { window.clearInterval(tTickRef.current); tTickRef.current = null; }
|
||||
};
|
||||
const scheduleRetry = (sec: number) => {
|
||||
clearTimers();
|
||||
const clamped = Math.min(Math.max(1, Math.floor(sec)), max);
|
||||
setRetryInSec(clamped);
|
||||
tTickRef.current = window.setInterval(() => {
|
||||
setRetryInSec(v => Math.max(0, Number(v) - 1));
|
||||
}, 1000);
|
||||
tRetryRef.current = window.setTimeout(() => {
|
||||
clearTimers();
|
||||
run();
|
||||
}, clamped * 1000);
|
||||
};
|
||||
const run = async () => {
|
||||
if (inFlightRef.current) return;
|
||||
try {
|
||||
inFlightRef.current = true;
|
||||
setLoading(true);
|
||||
const result = await fn();
|
||||
if (!mounted) return;
|
||||
clearTimers();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
if (!mounted) return;
|
||||
const httpErr = e as { status?: number; waitSec?: number; message?: string };
|
||||
if (httpErr?.status === 429) {
|
||||
const suggested = Number(httpErr?.waitSec) || delayRef.current;
|
||||
const next = Math.min(max, Math.max(base, suggested));
|
||||
delayRef.current = Math.min(max, Math.ceil(next * factor));
|
||||
setError(`Rate limited. Retrying in ${next}s...`);
|
||||
scheduleRetry(next);
|
||||
return;
|
||||
}
|
||||
setError(httpErr?.message || 'Failed to fetch');
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => { mounted = false; clearTimers(); };
|
||||
}, [fn, base, max, factor]);
|
||||
|
||||
return { data, error, loading, retryInSec } as const;
|
||||
}
|
||||
@ -12,7 +12,8 @@
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "server/src"]
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
@ -9,5 +10,22 @@ export default defineConfig({
|
||||
},
|
||||
preview: {
|
||||
port: 5173
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/setupTests.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}', 'server/src/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.{ts,tsx}', 'server/src/**/*.ts'],
|
||||
exclude: ['src/main.tsx', 'src/setupTests.ts', 'src/vite-env.d.ts', 'server/src/main.ts', '**/*.test.{ts,tsx}', '**/*.d.ts'],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
15895
TS/two-inputs/package-lock.json
generated
Normal file
15895
TS/two-inputs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,9 @@
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
"test": "ng test",
|
||||
"test:vitest": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
@ -29,12 +31,14 @@
|
||||
"@angular/cli": "^17.0.3",
|
||||
"@angular/compiler-cli": "^17.0.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.2.2"
|
||||
"typescript": "~5.2.2",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
0
TS/two-inputs/src/app/app.component.scss
Normal file
0
TS/two-inputs/src/app/app.component.scss
Normal file
170
TS/two-inputs/src/app/app.component.test.ts
Normal file
170
TS/two-inputs/src/app/app.component.test.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@angular/core", () => ({
|
||||
Component: () => (target: unknown) => target,
|
||||
}));
|
||||
vi.mock("@angular/common", () => ({ CommonModule: {} }));
|
||||
vi.mock("@angular/router", () => ({ RouterOutlet: {} }));
|
||||
vi.mock("@angular/material/input", () => ({ MatInputModule: {} }));
|
||||
vi.mock("@angular/material/button", () => ({ MatButtonModule: {} }));
|
||||
vi.mock("@angular/forms", () => ({ FormsModule: {} }));
|
||||
|
||||
vi.mock("./pair-logic", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./pair-logic")>();
|
||||
return {
|
||||
...actual,
|
||||
findCorrespondingValue: vi.fn(actual.findCorrespondingValue),
|
||||
findValidPairs: vi.fn(actual.findValidPairs),
|
||||
changeIndex: vi.fn(actual.changeIndex),
|
||||
};
|
||||
});
|
||||
|
||||
import { AppComponent } from "./app.component";
|
||||
import { findCorrespondingValue } from "./pair-logic";
|
||||
|
||||
describe("AppComponent", () => {
|
||||
let comp: AppComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(findCorrespondingValue).mockRestore();
|
||||
comp = new AppComponent();
|
||||
});
|
||||
|
||||
it("constructor initializes possibleValues with 6 symmetric pairs", () => {
|
||||
expect(comp.possibleValues).toEqual([
|
||||
[100, 225],
|
||||
[125, 200],
|
||||
[150, 175],
|
||||
[175, 150],
|
||||
[200, 125],
|
||||
[225, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ngOnInit recalculates possibleValues", () => {
|
||||
comp.step = 50;
|
||||
comp.min = 150;
|
||||
comp.max = 150;
|
||||
comp.targetValue = 300;
|
||||
comp.ngOnInit();
|
||||
expect(comp.possibleValues).toEqual([[150, 150]]);
|
||||
});
|
||||
|
||||
it("updateInput recalculates possibleValues", () => {
|
||||
comp.step = 50;
|
||||
comp.min = 150;
|
||||
comp.max = 150;
|
||||
comp.targetValue = 300;
|
||||
comp.updateInput();
|
||||
expect(comp.possibleValues).toEqual([[150, 150]]);
|
||||
});
|
||||
|
||||
it("upOne increments indexOne and updates pair", () => {
|
||||
comp.upOne();
|
||||
expect(comp.indexOne).toBe(1);
|
||||
expect(comp.inputOne).toBe(125);
|
||||
expect(comp.inputTwo).toBe(200);
|
||||
});
|
||||
|
||||
it("downOne wraps to last index and updates pair", () => {
|
||||
comp.downOne();
|
||||
expect(comp.indexOne).toBe(5);
|
||||
expect(comp.inputOne).toBe(225);
|
||||
expect(comp.inputTwo).toBe(100);
|
||||
});
|
||||
|
||||
it("upTwo increments indexTwo and updates pair", () => {
|
||||
comp.upTwo();
|
||||
expect(comp.indexTwo).toBe(1);
|
||||
expect(comp.inputTwo).toBe(200);
|
||||
expect(comp.inputOne).toBe(125);
|
||||
});
|
||||
|
||||
it("downTwo wraps to last index and updates pair", () => {
|
||||
comp.downTwo();
|
||||
expect(comp.indexTwo).toBe(5);
|
||||
expect(comp.inputTwo).toBe(100);
|
||||
expect(comp.inputOne).toBe(225);
|
||||
});
|
||||
|
||||
it("upOne wraps around at end", () => {
|
||||
comp.indexOne = 5;
|
||||
comp.upOne();
|
||||
expect(comp.indexOne).toBe(0);
|
||||
});
|
||||
|
||||
it("downOne wraps around at start", () => {
|
||||
comp.indexOne = 0;
|
||||
comp.downOne();
|
||||
expect(comp.indexOne).toBe(5);
|
||||
});
|
||||
|
||||
it("updateTwoValue does nothing when possibleValues is null", () => {
|
||||
comp.possibleValues = null;
|
||||
comp.updateTwoValue();
|
||||
expect(comp.inputOne).toBeNull();
|
||||
expect(comp.inputTwo).toBeNull();
|
||||
});
|
||||
|
||||
it("updateTwoValue logs error when inputOne is undefined", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
comp.possibleValues = [[undefined as unknown as number, 225]];
|
||||
comp.indexOne = 0;
|
||||
comp.updateTwoValue();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"this.inputOne is null or undefined!: ",
|
||||
undefined,
|
||||
expect.anything(),
|
||||
0,
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("updateTwoValue logs error when findCorrespondingValue returns null", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(findCorrespondingValue).mockReturnValueOnce(null);
|
||||
comp.possibleValues = [[100, 225]];
|
||||
comp.indexOne = 0;
|
||||
comp.updateTwoValue();
|
||||
expect(spy).toHaveBeenCalledWith("result is null!");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("updateInput sets possibleValues to null when step is null", () => {
|
||||
comp.step = null;
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
comp.updateInput();
|
||||
expect(comp.possibleValues).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("upTwo skips update when possibleValues becomes null", () => {
|
||||
comp.step = null;
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
comp.upTwo();
|
||||
expect(comp.possibleValues).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("upTwo handles findCorrespondingValue returning null", () => {
|
||||
vi.mocked(findCorrespondingValue).mockReturnValueOnce(null);
|
||||
const originalInputOne = comp.inputOne;
|
||||
comp.upTwo();
|
||||
expect(comp.inputOne).toBe(originalInputOne);
|
||||
});
|
||||
|
||||
it("downTwo skips update when possibleValues becomes null", () => {
|
||||
comp.step = null;
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
comp.downTwo();
|
||||
expect(comp.possibleValues).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("downTwo handles findCorrespondingValue returning null", () => {
|
||||
vi.mocked(findCorrespondingValue).mockReturnValueOnce(null);
|
||||
const originalInputOne = comp.inputOne;
|
||||
comp.downTwo();
|
||||
expect(comp.inputOne).toBe(originalInputOne);
|
||||
});
|
||||
});
|
||||
@ -1,20 +1,31 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Component } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { RouterOutlet } from "@angular/router";
|
||||
import { MatInputModule } from "@angular/material/input";
|
||||
import { MatButtonModule } from "@angular/material/button";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import {
|
||||
findValidPairs,
|
||||
findCorrespondingValue,
|
||||
changeIndex,
|
||||
} from "./pair-logic";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
selector: "app-root",
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, MatInputModule, FormsModule, MatButtonModule],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
MatInputModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
],
|
||||
templateUrl: "./app.component.html",
|
||||
styleUrls: ["./app.component.scss"],
|
||||
})
|
||||
export class AppComponent {
|
||||
inputOne: number | null = null; // Default initialization to 50
|
||||
inputTwo: number | null = null; // Default initialization to 50
|
||||
inputOne: number | null = null;
|
||||
inputTwo: number | null = null;
|
||||
min: number | null = 100;
|
||||
max: number | null = 250;
|
||||
step: number | null = 25;
|
||||
@ -24,115 +35,101 @@ export class AppComponent {
|
||||
possibleValues: Array<[number, number]> | null = [];
|
||||
|
||||
constructor() {
|
||||
this.possibleValues = AppComponent.findValidPairs(this.step, this.min, this.max, this.targetValue);
|
||||
this.possibleValues = findValidPairs(
|
||||
this.step,
|
||||
this.min,
|
||||
this.max,
|
||||
this.targetValue,
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.possibleValues = AppComponent.findValidPairs(this.step, this.min, this.max, this.targetValue);
|
||||
this.possibleValues = findValidPairs(
|
||||
this.step,
|
||||
this.min,
|
||||
this.max,
|
||||
this.targetValue,
|
||||
);
|
||||
}
|
||||
|
||||
public updateInput() {
|
||||
this.possibleValues = AppComponent.findValidPairs(this.step, this.min, this.max, this.targetValue);
|
||||
this.possibleValues = findValidPairs(
|
||||
this.step,
|
||||
this.min,
|
||||
this.max,
|
||||
this.targetValue,
|
||||
);
|
||||
}
|
||||
|
||||
private changeIndex(currentValue: number, direction: boolean) {
|
||||
this.possibleValues = AppComponent.findValidPairs(this.step, this.min, this.max, this.targetValue);
|
||||
private doChangeIndex(currentValue: number, direction: boolean) {
|
||||
this.possibleValues = findValidPairs(
|
||||
this.step,
|
||||
this.min,
|
||||
this.max,
|
||||
this.targetValue,
|
||||
);
|
||||
const length = this.possibleValues?.length;
|
||||
if(typeof length !== "undefined") {
|
||||
if(direction) {
|
||||
if(currentValue + 1 > length - 1) {
|
||||
return 0;
|
||||
}
|
||||
return currentValue + 1;
|
||||
} else {
|
||||
if(currentValue - 1 < 0) {
|
||||
return length - 1;
|
||||
}
|
||||
return currentValue - 1;
|
||||
}
|
||||
} else {
|
||||
console.error(`appComponent, changeIndex, length is undefined!`, length);
|
||||
}
|
||||
return currentValue;
|
||||
return changeIndex(currentValue, direction, length);
|
||||
}
|
||||
|
||||
updateTwoValue() {
|
||||
if(this.possibleValues !== null) {
|
||||
if (this.possibleValues !== null) {
|
||||
this.inputOne = this.possibleValues[this.indexOne][0];
|
||||
if(typeof this.inputOne !== "undefined" && this.inputOne !== null) {
|
||||
const result = AppComponent.findCorrespondingValue(this.possibleValues, this.inputOne);
|
||||
if(result !== null) {
|
||||
[this.inputTwo, this.indexTwo] = result;
|
||||
return;
|
||||
if (typeof this.inputOne !== "undefined" && this.inputOne !== null) {
|
||||
const result = findCorrespondingValue(
|
||||
this.possibleValues,
|
||||
this.inputOne,
|
||||
);
|
||||
if (result !== null) {
|
||||
[this.inputTwo, this.indexTwo] = result;
|
||||
return;
|
||||
}
|
||||
console.error("result is null!");
|
||||
}
|
||||
console.error(`result is null!`);
|
||||
}
|
||||
console.error(`this.inputOne is null or undefined!: `, this.inputOne, this.possibleValues, this.indexOne);
|
||||
console.error(
|
||||
"this.inputOne is null or undefined!: ",
|
||||
this.inputOne,
|
||||
this.possibleValues,
|
||||
this.indexOne,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
upOne() {
|
||||
this.indexOne = this.changeIndex(this.indexOne, true);
|
||||
this.indexOne = this.doChangeIndex(this.indexOne, true);
|
||||
this.updateTwoValue();
|
||||
}
|
||||
|
||||
downOne() {
|
||||
this.indexOne = this.changeIndex(this.indexOne, false);
|
||||
this.indexOne = this.doChangeIndex(this.indexOne, false);
|
||||
this.updateTwoValue();
|
||||
}
|
||||
|
||||
upTwo() {
|
||||
this.indexTwo = this.changeIndex(this.indexTwo, true);
|
||||
if(this.possibleValues !== null) {
|
||||
this.indexTwo = this.doChangeIndex(this.indexTwo, true);
|
||||
if (this.possibleValues !== null) {
|
||||
this.inputTwo = this.possibleValues[this.indexTwo][1];
|
||||
const result = AppComponent.findCorrespondingValue(this.possibleValues, this.inputTwo);
|
||||
if(result !== null) {
|
||||
const result = findCorrespondingValue(
|
||||
this.possibleValues,
|
||||
this.inputTwo,
|
||||
);
|
||||
if (result !== null) {
|
||||
[this.inputOne, this.indexOne] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downTwo() {
|
||||
this.indexTwo = this.changeIndex(this.indexTwo, false);
|
||||
if(this.possibleValues !== null) {
|
||||
this.indexTwo = this.doChangeIndex(this.indexTwo, false);
|
||||
if (this.possibleValues !== null) {
|
||||
this.inputTwo = this.possibleValues[this.indexTwo][1];
|
||||
const result = AppComponent.findCorrespondingValue(this.possibleValues, this.inputTwo);
|
||||
if(result !== null) {
|
||||
const result = findCorrespondingValue(
|
||||
this.possibleValues,
|
||||
this.inputTwo,
|
||||
);
|
||||
if (result !== null) {
|
||||
[this.inputOne, this.indexOne] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static findCorrespondingValue(pairs: Array<[number, number]>, number: number): [number, number] | null {
|
||||
for (let index = 0; index < pairs.length; index += 1) {
|
||||
if (pairs[index][0] === number) {
|
||||
return [pairs[index][1], index]; // Return n2 if the given number matches n1
|
||||
} else if (pairs[index][1] === number) {
|
||||
return [pairs[index][0], index]; // Return n1 if the given number matches n2
|
||||
}
|
||||
}
|
||||
console.error("No corresponding value found for the provided number in the pairs.", pairs, number);
|
||||
return null; // Return null if no matching number is found
|
||||
}
|
||||
|
||||
private static findValidPairs(x: number | null, y: number | null, z: number | null, ml: number | null): Array<[number, number]> | null {
|
||||
if (x === null || y === null || z === null || ml === null) {
|
||||
console.error("findValidPairs, some value is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
const results: Array<[number, number]> = [];
|
||||
|
||||
// Iterate through possible values of n1, which must be multiples of x, at least y, and not more than z
|
||||
for (let n1 = y; n1 <= ml - y && n1 <= z; n1 += x) {
|
||||
const n2 = ml - n1;
|
||||
// Ensure n2 is also a multiple of x, n2 >= y, and n2 <= z
|
||||
if (n2 % x === 0 && n2 >= y && n2 <= z) {
|
||||
results.push([n1, n2]);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
95
TS/two-inputs/src/app/pair-logic.test.ts
Normal file
95
TS/two-inputs/src/app/pair-logic.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { findValidPairs, findCorrespondingValue, changeIndex } from "./pair-logic";
|
||||
|
||||
describe("findValidPairs", () => {
|
||||
it("returns null when any argument is null", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(findValidPairs(null, 100, 250, 325)).toBeNull();
|
||||
expect(findValidPairs(25, null, 250, 325)).toBeNull();
|
||||
expect(findValidPairs(25, 100, null, 325)).toBeNull();
|
||||
expect(findValidPairs(25, 100, 250, null)).toBeNull();
|
||||
expect(spy).toHaveBeenCalledTimes(4);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns valid pairs for step=25, min=100, max=250, target=325", () => {
|
||||
const result = findValidPairs(25, 100, 250, 325);
|
||||
expect(result).toEqual([
|
||||
[100, 225],
|
||||
[125, 200],
|
||||
[150, 175],
|
||||
[175, 150],
|
||||
[200, 125],
|
||||
[225, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when no valid pairs exist", () => {
|
||||
const result = findValidPairs(25, 200, 250, 325);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns symmetric pairs", () => {
|
||||
const result = findValidPairs(50, 100, 200, 300);
|
||||
expect(result).toEqual([
|
||||
[100, 200],
|
||||
[150, 150],
|
||||
[200, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles case where n2 is not a multiple of step", () => {
|
||||
const result = findValidPairs(30, 100, 250, 325);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCorrespondingValue", () => {
|
||||
const pairs: Array<[number, number]> = [
|
||||
[100, 225],
|
||||
[125, 200],
|
||||
[150, 175],
|
||||
];
|
||||
|
||||
it("returns match on first element", () => {
|
||||
expect(findCorrespondingValue(pairs, 100)).toEqual([225, 0]);
|
||||
expect(findCorrespondingValue(pairs, 125)).toEqual([200, 1]);
|
||||
});
|
||||
|
||||
it("returns match on second element", () => {
|
||||
expect(findCorrespondingValue(pairs, 225)).toEqual([100, 0]);
|
||||
expect(findCorrespondingValue(pairs, 200)).toEqual([125, 1]);
|
||||
});
|
||||
|
||||
it("returns null when no match found", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(findCorrespondingValue(pairs, 999)).toBeNull();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeIndex", () => {
|
||||
it("wraps forward past end", () => {
|
||||
expect(changeIndex(4, true, 5)).toBe(0);
|
||||
});
|
||||
|
||||
it("increments normally forward", () => {
|
||||
expect(changeIndex(2, true, 5)).toBe(3);
|
||||
});
|
||||
|
||||
it("wraps backward past start", () => {
|
||||
expect(changeIndex(0, false, 5)).toBe(4);
|
||||
});
|
||||
|
||||
it("decrements normally backward", () => {
|
||||
expect(changeIndex(3, false, 5)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns currentValue when length is undefined", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(changeIndex(3, true, undefined)).toBe(3);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
67
TS/two-inputs/src/app/pair-logic.ts
Normal file
67
TS/two-inputs/src/app/pair-logic.ts
Normal file
@ -0,0 +1,67 @@
|
||||
export function findValidPairs(
|
||||
x: number | null,
|
||||
y: number | null,
|
||||
z: number | null,
|
||||
ml: number | null,
|
||||
): Array<[number, number]> | null {
|
||||
if (x === null || y === null || z === null || ml === null) {
|
||||
console.error("findValidPairs, some value is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
const results: Array<[number, number]> = [];
|
||||
|
||||
for (let n1 = y; n1 <= ml - y && n1 <= z; n1 += x) {
|
||||
const n2 = ml - n1;
|
||||
if (n2 % x === 0 && n2 >= y && n2 <= z) {
|
||||
results.push([n1, n2]);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function findCorrespondingValue(
|
||||
pairs: Array<[number, number]>,
|
||||
number: number,
|
||||
): [number, number] | null {
|
||||
for (let index = 0; index < pairs.length; index += 1) {
|
||||
if (pairs[index][0] === number) {
|
||||
return [pairs[index][1], index];
|
||||
} else if (pairs[index][1] === number) {
|
||||
return [pairs[index][0], index];
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
"No corresponding value found for the provided number in the pairs.",
|
||||
pairs,
|
||||
number,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function changeIndex(
|
||||
currentValue: number,
|
||||
direction: boolean,
|
||||
length: number | undefined,
|
||||
): number {
|
||||
if (typeof length !== "undefined") {
|
||||
if (direction) {
|
||||
if (currentValue + 1 > length - 1) {
|
||||
return 0;
|
||||
}
|
||||
return currentValue + 1;
|
||||
} else {
|
||||
if (currentValue - 1 < 0) {
|
||||
return length - 1;
|
||||
}
|
||||
return currentValue - 1;
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"appComponent, changeIndex, length is undefined!",
|
||||
length,
|
||||
);
|
||||
}
|
||||
return currentValue;
|
||||
}
|
||||
21
TS/two-inputs/vitest.config.ts
Normal file
21
TS/two-inputs/vitest.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/app/**/*.ts"],
|
||||
exclude: ["**/*.test.ts", "**/*.d.ts"],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,28 +1,177 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
analyzer:
|
||||
errors:
|
||||
missing_return: error
|
||||
missing_required_param: error
|
||||
todo: warning
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
# Error rules
|
||||
always_use_package_imports: true
|
||||
avoid_dynamic_calls: true
|
||||
avoid_slow_async_io: true
|
||||
avoid_type_to_string: true
|
||||
avoid_types_as_parameter_names: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
literal_only_boolean_expressions: true
|
||||
no_adjacent_strings_in_list: true
|
||||
prefer_void_to_null: true
|
||||
test_types_in_equals: true
|
||||
throw_in_finally: true
|
||||
unnecessary_statements: true
|
||||
# Style rules
|
||||
always_declare_return_types: true
|
||||
always_put_required_named_parameters_first: true
|
||||
annotate_overrides: true
|
||||
avoid_annotating_with_dynamic: true
|
||||
avoid_bool_literals_in_conditional_expressions: true
|
||||
avoid_catching_errors: true
|
||||
avoid_double_and_int_checks: true
|
||||
avoid_empty_else: true
|
||||
avoid_equals_and_hash_code_on_mutable_classes: true
|
||||
avoid_escaping_inner_quotes: true
|
||||
avoid_field_initializers_in_const_classes: true
|
||||
avoid_final_parameters: true
|
||||
avoid_function_literals_in_foreach_calls: true
|
||||
avoid_implementing_value_types: true
|
||||
avoid_init_to_null: true
|
||||
avoid_multiple_declarations_per_line: true
|
||||
avoid_positional_boolean_parameters: true
|
||||
avoid_print: true
|
||||
avoid_private_typedef_functions: true
|
||||
avoid_redundant_argument_values: true
|
||||
avoid_relative_lib_imports: true
|
||||
avoid_renaming_method_parameters: true
|
||||
avoid_return_types_on_setters: true
|
||||
avoid_returning_null_for_void: true
|
||||
avoid_returning_this: true
|
||||
avoid_setters_without_getters: true
|
||||
avoid_shadowing_type_parameters: true
|
||||
avoid_single_cascade_in_expression_statements: true
|
||||
avoid_unnecessary_containers: true
|
||||
avoid_unused_constructor_parameters: true
|
||||
avoid_void_async: true
|
||||
cascade_invocations: true
|
||||
cast_nullable_to_non_nullable: true
|
||||
combinators_ordering: true
|
||||
conditional_uri_does_not_exist: true
|
||||
curly_braces_in_flow_control_structures: true
|
||||
dangling_library_doc_comments: true
|
||||
deprecated_consistency: true
|
||||
directives_ordering: true
|
||||
empty_catches: true
|
||||
empty_constructor_bodies: true
|
||||
eol_at_end_of_file: true
|
||||
exhaustive_cases: true
|
||||
file_names: true
|
||||
hash_and_equals: true
|
||||
implementation_imports: true
|
||||
implicit_call_tearoffs: true
|
||||
join_return_with_assignment: true
|
||||
leading_newlines_in_multiline_strings: true
|
||||
library_annotations: true
|
||||
library_names: true
|
||||
library_prefixes: true
|
||||
missing_whitespace_between_adjacent_strings: true
|
||||
no_default_cases: true
|
||||
no_leading_underscores_for_library_prefixes: true
|
||||
no_leading_underscores_for_local_identifiers: true
|
||||
no_literal_bool_comparisons: true
|
||||
no_runtimeType_toString: true
|
||||
non_constant_identifier_names: true
|
||||
noop_primitive_operations: true
|
||||
null_check_on_nullable_type_parameter: true
|
||||
null_closures: true
|
||||
omit_local_variable_types: true
|
||||
one_member_abstracts: true
|
||||
only_throw_errors: true
|
||||
overridden_fields: true
|
||||
package_prefixed_library_names: true
|
||||
parameter_assignments: true
|
||||
prefer_adjacent_string_concatenation: true
|
||||
prefer_asserts_in_initializer_lists: true
|
||||
prefer_collection_literals: true
|
||||
prefer_conditional_assignment: true
|
||||
prefer_const_constructors: true
|
||||
prefer_const_constructors_in_immutables: true
|
||||
prefer_const_declarations: true
|
||||
prefer_const_literals_to_create_immutables: true
|
||||
prefer_constructors_over_static_methods: true
|
||||
prefer_contains: true
|
||||
prefer_expression_function_bodies: true
|
||||
prefer_final_fields: true
|
||||
prefer_final_in_for_each: true
|
||||
prefer_final_locals: true
|
||||
prefer_for_elements_to_map_fromIterable: true
|
||||
prefer_function_declarations_over_variables: true
|
||||
prefer_generic_function_type_aliases: true
|
||||
prefer_if_elements_to_conditional_expressions: true
|
||||
prefer_if_null_operators: true
|
||||
prefer_initializing_formals: true
|
||||
prefer_inlined_adds: true
|
||||
prefer_int_literals: true
|
||||
prefer_interpolation_to_compose_strings: true
|
||||
prefer_is_empty: true
|
||||
prefer_is_not_empty: true
|
||||
prefer_is_not_operator: true
|
||||
prefer_iterable_whereType: true
|
||||
prefer_null_aware_method_calls: true
|
||||
prefer_null_aware_operators: true
|
||||
prefer_single_quotes: true
|
||||
prefer_spread_collections: true
|
||||
prefer_typing_uninitialized_variables: true
|
||||
provide_deprecation_message: true
|
||||
recursive_getters: true
|
||||
require_trailing_commas: true
|
||||
sized_box_for_whitespace: true
|
||||
slash_for_doc_comments: true
|
||||
sort_child_properties_last: true
|
||||
sort_constructors_first: true
|
||||
sort_unnamed_constructors_first: true
|
||||
type_annotate_public_apis: true
|
||||
type_init_formals: true
|
||||
unawaited_futures: true
|
||||
unnecessary_await_in_return: true
|
||||
unnecessary_brace_in_string_interps: true
|
||||
unnecessary_breaks: true
|
||||
unnecessary_const: true
|
||||
unnecessary_constructor_name: true
|
||||
unnecessary_getters_setters: true
|
||||
unnecessary_lambdas: true
|
||||
unnecessary_late: true
|
||||
unnecessary_library_directive: true
|
||||
unnecessary_new: true
|
||||
unnecessary_null_aware_assignments: true
|
||||
unnecessary_null_aware_operator_on_extension_on_nullable: true
|
||||
unnecessary_null_checks: true
|
||||
unnecessary_null_in_if_null_operators: true
|
||||
unnecessary_nullable_for_final_variable_declarations: true
|
||||
unnecessary_overrides: true
|
||||
unnecessary_parenthesis: true
|
||||
unnecessary_raw_strings: true
|
||||
unnecessary_string_escapes: true
|
||||
unnecessary_string_interpolations: true
|
||||
unnecessary_this: true
|
||||
unnecessary_to_list_in_spreads: true
|
||||
unreachable_from_main: true
|
||||
use_colored_box: true
|
||||
use_decorated_box: true
|
||||
use_enums: true
|
||||
use_full_hex_values_for_flutter_colors: true
|
||||
use_function_type_syntax_for_parameters: true
|
||||
use_is_even_rather_than_modulo: true
|
||||
use_named_constants: true
|
||||
use_raw_strings: true
|
||||
use_rethrow_when_possible: true
|
||||
use_setters_to_change_properties: true
|
||||
use_string_buffers: true
|
||||
use_string_in_part_of_directives: true
|
||||
use_super_parameters: true
|
||||
use_test_throws_matchers: true
|
||||
use_to_and_as_if_applicable: true
|
||||
void_checks: true
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'screens/pomodoro_screen.dart';
|
||||
import 'theme/pomodoro_theme.dart';
|
||||
import 'package:pomodoro_app/screens/pomodoro_screen.dart';
|
||||
import 'package:pomodoro_app/theme/pomodoro_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const PomodoroApp());
|
||||
@ -13,12 +13,10 @@ class PomodoroApp extends StatelessWidget {
|
||||
const PomodoroApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
title: 'Pomodoro',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: PomodoroTheme.darkTheme,
|
||||
home: const PomodoroScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Defines the timer style (technique) the user can choose.
|
||||
enum TimerStyle {
|
||||
/// Classic Pomodoro: 25 min work, 5 min short break, 15 min long break.
|
||||
@ -88,6 +90,7 @@ extension PomodoroModeLabel on PomodoroMode {
|
||||
}
|
||||
|
||||
/// Immutable snapshot of the Pomodoro timer state.
|
||||
@immutable
|
||||
class PomodoroState {
|
||||
/// Creates a [PomodoroState].
|
||||
const PomodoroState({
|
||||
@ -102,8 +105,6 @@ class PomodoroState {
|
||||
/// Creates the default initial state.
|
||||
factory PomodoroState.initial({
|
||||
int workMinutes = 25,
|
||||
int shortBreakMinutes = 5,
|
||||
int longBreakMinutes = 15,
|
||||
int pomodorosPerCycle = 4,
|
||||
}) {
|
||||
final totalSeconds = workMinutes * 60;
|
||||
@ -137,8 +138,8 @@ class PomodoroState {
|
||||
|
||||
/// Progress as a value between 0.0 and 1.0.
|
||||
double get progress {
|
||||
if (totalSeconds == 0) return 1.0;
|
||||
return 1.0 - (remainingSeconds / totalSeconds);
|
||||
if (totalSeconds == 0) return 1;
|
||||
return 1 - (remainingSeconds / totalSeconds);
|
||||
}
|
||||
|
||||
/// Display label for the current mode, context-aware.
|
||||
@ -168,16 +169,15 @@ class PomodoroState {
|
||||
bool? isRunning,
|
||||
int? completedPomodoros,
|
||||
int? pomodorosPerCycle,
|
||||
}) {
|
||||
return PomodoroState(
|
||||
mode: mode ?? this.mode,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
totalSeconds: totalSeconds ?? this.totalSeconds,
|
||||
isRunning: isRunning ?? this.isRunning,
|
||||
completedPomodoros: completedPomodoros ?? this.completedPomodoros,
|
||||
pomodorosPerCycle: pomodorosPerCycle ?? this.pomodorosPerCycle,
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
PomodoroState(
|
||||
mode: mode ?? this.mode,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
totalSeconds: totalSeconds ?? this.totalSeconds,
|
||||
isRunning: isRunning ?? this.isRunning,
|
||||
completedPomodoros: completedPomodoros ?? this.completedPomodoros,
|
||||
pomodorosPerCycle: pomodorosPerCycle ?? this.pomodorosPerCycle,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@ -192,8 +192,7 @@ class PomodoroState {
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
int get hashCode => Object.hash(
|
||||
mode,
|
||||
remainingSeconds,
|
||||
totalSeconds,
|
||||
@ -201,5 +200,4 @@ class PomodoroState {
|
||||
completedPomodoros,
|
||||
pomodorosPerCycle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import '../services/pomodoro_timer.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/sound_service.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../widgets/pomodoro_indicators.dart';
|
||||
import '../widgets/timer_controls.dart';
|
||||
import '../widgets/timer_display.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/services/notification_service.dart';
|
||||
import 'package:pomodoro_app/services/pomodoro_timer.dart';
|
||||
import 'package:pomodoro_app/services/sound_service.dart';
|
||||
import 'package:pomodoro_app/services/sync_service.dart';
|
||||
import 'package:pomodoro_app/widgets/pomodoro_indicators.dart';
|
||||
import 'package:pomodoro_app/widgets/timer_controls.dart';
|
||||
import 'package:pomodoro_app/widgets/timer_display.dart';
|
||||
|
||||
/// The main screen of the Pomodoro app.
|
||||
///
|
||||
@ -42,7 +42,7 @@ class PomodoroScreenState extends State<PomodoroScreen> {
|
||||
|
||||
if (widget.timer != null) {
|
||||
// Test path: synchronous init, no sync service needed.
|
||||
_timer = widget.timer!;
|
||||
_timer = widget.timer;
|
||||
_syncService = widget.syncService;
|
||||
_timer!.addListener(_onTimerChanged);
|
||||
_initialized = true;
|
||||
@ -54,7 +54,7 @@ class PomodoroScreenState extends State<PomodoroScreen> {
|
||||
|
||||
Future<void> _initAsync() async {
|
||||
_syncService = SyncService(
|
||||
onStateReceived: _onRemoteState,
|
||||
onStateReceived: onRemoteState,
|
||||
);
|
||||
_ownsSyncService = true;
|
||||
await _syncService!.start();
|
||||
@ -71,7 +71,9 @@ class PomodoroScreenState extends State<PomodoroScreen> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onRemoteState(PomodoroState state, String action) {
|
||||
/// Handles state received from a remote device.
|
||||
@visibleForTesting
|
||||
void onRemoteState(PomodoroState state, String action) {
|
||||
_timer?.applyRemoteState(state, action);
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
|
||||
/// Sends desktop notifications showing Pomodoro timer status.
|
||||
///
|
||||
|
||||
@ -2,10 +2,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import 'notification_service.dart';
|
||||
import 'sound_service.dart';
|
||||
import 'sync_service.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/services/notification_service.dart';
|
||||
import 'package:pomodoro_app/services/sound_service.dart';
|
||||
import 'package:pomodoro_app/services/sync_service.dart';
|
||||
|
||||
/// Manages the Pomodoro timer logic, independent of UI framework.
|
||||
///
|
||||
@ -32,8 +32,6 @@ class PomodoroTimer extends ChangeNotifier {
|
||||
_pomodorosPerCycle = pomodorosPerCycle ?? timerStyle.defaultPomodorosPerCycle;
|
||||
_state = PomodoroState.initial(
|
||||
workMinutes: _workMinutes,
|
||||
shortBreakMinutes: _shortBreakMinutes,
|
||||
longBreakMinutes: _longBreakMinutes,
|
||||
pomodorosPerCycle: _pomodorosPerCycle,
|
||||
);
|
||||
}
|
||||
@ -84,8 +82,6 @@ class PomodoroTimer extends ChangeNotifier {
|
||||
_pomodorosPerCycle = style.defaultPomodorosPerCycle;
|
||||
_state = PomodoroState.initial(
|
||||
workMinutes: _workMinutes,
|
||||
shortBreakMinutes: _shortBreakMinutes,
|
||||
longBreakMinutes: _longBreakMinutes,
|
||||
pomodorosPerCycle: _pomodorosPerCycle,
|
||||
);
|
||||
_notificationService?.cancel();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
|
||||
/// Plays notification sounds for Pomodoro timer transitions.
|
||||
///
|
||||
@ -16,9 +16,12 @@ class SoundService {
|
||||
/// Pass a custom [playCallback] for testing.
|
||||
SoundService({
|
||||
@visibleForTesting Future<void> Function(String assetPath)? playCallback,
|
||||
}) : _playCallback = playCallback;
|
||||
@visibleForTesting AudioPlayer Function()? playerFactory,
|
||||
}) : _playCallback = playCallback,
|
||||
_playerFactory = playerFactory ?? AudioPlayer.new;
|
||||
|
||||
final Future<void> Function(String assetPath)? _playCallback;
|
||||
final AudioPlayer Function() _playerFactory;
|
||||
AudioPlayer? _player;
|
||||
bool _disposed = false;
|
||||
|
||||
@ -41,8 +44,8 @@ class SoundService {
|
||||
if (_playCallback != null) {
|
||||
await _playCallback(assetPath);
|
||||
} else {
|
||||
_player?.dispose();
|
||||
_player = AudioPlayer();
|
||||
await _player?.dispose();
|
||||
_player = _playerFactory();
|
||||
await _player!.play(AssetSource('$_assetPrefix/$assetPath'));
|
||||
}
|
||||
debugPrint('SoundService: Playing $assetPath');
|
||||
|
||||
@ -6,7 +6,7 @@ import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
|
||||
/// Callback type for receiving a synced [PomodoroState] and action name.
|
||||
typedef SyncCallback = void Function(PomodoroState state, String action);
|
||||
@ -26,10 +26,12 @@ class SyncService {
|
||||
required this.onStateReceived,
|
||||
this.port = 41234,
|
||||
@visibleForTesting String? deviceId,
|
||||
@visibleForTesting bool? isAndroid,
|
||||
@visibleForTesting
|
||||
Future<RawDatagramSocket> Function(dynamic host, int port)?
|
||||
Future<RawDatagramSocket> Function(Object host, int port)?
|
||||
socketFactory,
|
||||
}) : deviceId = deviceId ?? _generateDeviceId(),
|
||||
_isAndroid = isAndroid ?? Platform.isAndroid,
|
||||
_socketFactory = socketFactory;
|
||||
|
||||
/// Unique identifier for this device instance.
|
||||
@ -45,8 +47,9 @@ class SyncService {
|
||||
/// Called when a state update is received from another device.
|
||||
final SyncCallback onStateReceived;
|
||||
|
||||
final Future<RawDatagramSocket> Function(dynamic host, int port)?
|
||||
final Future<RawDatagramSocket> Function(Object host, int port)?
|
||||
_socketFactory;
|
||||
final bool _isAndroid;
|
||||
|
||||
RawDatagramSocket? _socket;
|
||||
Timer? _heartbeat;
|
||||
@ -71,7 +74,6 @@ class SyncService {
|
||||
_socket = await RawDatagramSocket.bind(
|
||||
InternetAddress.anyIPv4,
|
||||
port,
|
||||
reuseAddress: true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -80,7 +82,6 @@ class SyncService {
|
||||
_socket?.listen(
|
||||
_onSocketEvent,
|
||||
onError: _onError,
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
debugPrint('SyncService: Listening on port $port (device=$deviceId)');
|
||||
@ -115,10 +116,13 @@ class SyncService {
|
||||
/// Starts periodic heartbeat that broadcasts current state.
|
||||
///
|
||||
/// This keeps devices in sync even if an individual message is lost.
|
||||
void startHeartbeat(PomodoroState Function() stateProvider) {
|
||||
void startHeartbeat(
|
||||
PomodoroState Function() stateProvider, {
|
||||
@visibleForTesting Duration interval = const Duration(seconds: 5),
|
||||
}) {
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = Timer.periodic(
|
||||
const Duration(seconds: 5),
|
||||
interval,
|
||||
(_) => broadcast(stateProvider(), 'heartbeat'),
|
||||
);
|
||||
}
|
||||
@ -198,8 +202,7 @@ class SyncService {
|
||||
return utf8.encode(jsonEncode(map));
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _encodeState(PomodoroState state) {
|
||||
return {
|
||||
static Map<String, dynamic> _encodeState(PomodoroState state) => {
|
||||
'mode': state.mode.name,
|
||||
'remainingSeconds': state.remainingSeconds,
|
||||
'totalSeconds': state.totalSeconds,
|
||||
@ -207,21 +210,19 @@ class SyncService {
|
||||
'completedPomodoros': state.completedPomodoros,
|
||||
'pomodorosPerCycle': state.pomodorosPerCycle,
|
||||
};
|
||||
}
|
||||
|
||||
static PomodoroState _decodeState(Map<String, dynamic> map) {
|
||||
return PomodoroState(
|
||||
mode: PomodoroMode.values.byName(map['mode'] as String),
|
||||
remainingSeconds: map['remainingSeconds'] as int,
|
||||
totalSeconds: map['totalSeconds'] as int,
|
||||
isRunning: map['isRunning'] as bool,
|
||||
completedPomodoros: map['completedPomodoros'] as int,
|
||||
pomodorosPerCycle: map['pomodorosPerCycle'] as int,
|
||||
);
|
||||
}
|
||||
static PomodoroState _decodeState(Map<String, dynamic> map) =>
|
||||
PomodoroState(
|
||||
mode: PomodoroMode.values.byName(map['mode'] as String),
|
||||
remainingSeconds: map['remainingSeconds'] as int,
|
||||
totalSeconds: map['totalSeconds'] as int,
|
||||
isRunning: map['isRunning'] as bool,
|
||||
completedPomodoros: map['completedPomodoros'] as int,
|
||||
pomodorosPerCycle: map['pomodorosPerCycle'] as int,
|
||||
);
|
||||
|
||||
static Future<void> _acquireMulticastLock() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
Future<void> _acquireMulticastLock() async {
|
||||
if (!_isAndroid) return;
|
||||
try {
|
||||
await _methodChannel.invokeMethod<bool>('acquire');
|
||||
} on MissingPluginException {
|
||||
@ -231,8 +232,8 @@ class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _releaseMulticastLock() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
Future<void> _releaseMulticastLock() async {
|
||||
if (!_isAndroid) return;
|
||||
try {
|
||||
await _methodChannel.invokeMethod<bool>('release');
|
||||
} on MissingPluginException {
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
|
||||
/// Provides consistent theming for the Pomodoro app across platforms.
|
||||
class PomodoroTheme {
|
||||
PomodoroTheme._();
|
||||
abstract final class PomodoroTheme {
|
||||
|
||||
// Brand colors per mode.
|
||||
static const Color workColor = Color(0xFFE74C3C);
|
||||
@ -29,8 +28,7 @@ class PomodoroTheme {
|
||||
}
|
||||
|
||||
/// The app's dark theme.
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
static ThemeData get darkTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _darkBackground,
|
||||
@ -69,5 +67,4 @@ class PomodoroTheme {
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import '../theme/pomodoro_theme.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/theme/pomodoro_theme.dart';
|
||||
|
||||
/// Shows completed pomodoro indicators as filled/unfilled dots.
|
||||
class PomodoroIndicators extends StatelessWidget {
|
||||
@ -15,8 +15,7 @@ class PomodoroIndicators extends StatelessWidget {
|
||||
final PomodoroState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
Widget build(BuildContext context) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
state.pomodorosPerCycle,
|
||||
@ -43,5 +42,4 @@ class PomodoroIndicators extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import '../theme/pomodoro_theme.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/theme/pomodoro_theme.dart';
|
||||
|
||||
/// Row of control buttons for the Pomodoro timer.
|
||||
class TimerControls extends StatelessWidget {
|
||||
|
||||
@ -2,8 +2,8 @@ import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/pomodoro_state.dart';
|
||||
import '../theme/pomodoro_theme.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/theme/pomodoro_theme.dart';
|
||||
|
||||
/// A circular progress indicator that displays the remaining time.
|
||||
class TimerDisplay extends StatelessWidget {
|
||||
@ -32,7 +32,7 @@ class TimerDisplay extends StatelessWidget {
|
||||
// Background circle.
|
||||
SizedBox.expand(
|
||||
child: CircularProgressIndicator(
|
||||
value: 1.0,
|
||||
value: 1,
|
||||
strokeWidth: 8,
|
||||
color: color.withValues(alpha: 0.2),
|
||||
),
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pomodoro_app/main.dart' as app;
|
||||
import 'package:pomodoro_app/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('main() runs the app', (tester) async {
|
||||
app.main();
|
||||
await tester.pump();
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('PomodoroApp builds and shows MaterialApp', (tester) async {
|
||||
await tester.pumpWidget(const PomodoroApp());
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
|
||||
@ -45,8 +45,6 @@ void main() {
|
||||
test('creates state with custom durations', () {
|
||||
final state = PomodoroState.initial(
|
||||
workMinutes: 30,
|
||||
shortBreakMinutes: 10,
|
||||
longBreakMinutes: 20,
|
||||
pomodorosPerCycle: 3,
|
||||
);
|
||||
expect(state.remainingSeconds, 30 * 60);
|
||||
|
||||
@ -43,8 +43,8 @@ void main() {
|
||||
late FakeTimerController fakeController;
|
||||
|
||||
Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
|
||||
fakeController = FakeTimerController();
|
||||
fakeController._callback = callback;
|
||||
fakeController = FakeTimerController()
|
||||
.._callback = callback;
|
||||
return _FakeTimer(fakeController);
|
||||
}
|
||||
|
||||
@ -62,11 +62,9 @@ void main() {
|
||||
timer.dispose();
|
||||
});
|
||||
|
||||
Widget createApp() {
|
||||
return MaterialApp(
|
||||
Widget createApp() => MaterialApp(
|
||||
home: PomodoroScreen(timer: timer),
|
||||
);
|
||||
}
|
||||
|
||||
group('PomodoroScreen', () {
|
||||
testWidgets('shows initial time', (tester) async {
|
||||
@ -132,8 +130,9 @@ void main() {
|
||||
// Start and tick.
|
||||
await tester.tap(find.byIcon(Icons.play_arrow));
|
||||
await tester.pump();
|
||||
fakeController.tick();
|
||||
fakeController.tick();
|
||||
fakeController
|
||||
..tick()
|
||||
..tick();
|
||||
await tester.pump();
|
||||
|
||||
// Reset.
|
||||
@ -203,5 +202,28 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(find.text('25:00'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('onRemoteState delegates to timer', (tester) async {
|
||||
await tester.pumpWidget(createApp());
|
||||
|
||||
final state = tester.state<PomodoroScreenState>(
|
||||
find.byType(PomodoroScreen),
|
||||
);
|
||||
|
||||
const remoteState = PomodoroState(
|
||||
mode: PomodoroMode.shortBreak,
|
||||
remainingSeconds: 200,
|
||||
totalSeconds: 300,
|
||||
isRunning: false,
|
||||
completedPomodoros: 2,
|
||||
pomodorosPerCycle: 4,
|
||||
);
|
||||
|
||||
state.onRemoteState(remoteState, 'pause');
|
||||
await tester.pump();
|
||||
|
||||
expect(timer.state.mode, PomodoroMode.shortBreak);
|
||||
expect(timer.state.remainingSeconds, 200);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('showTimer sends Notify via gdbus', () async {
|
||||
final state = PomodoroState(
|
||||
const state = PomodoroState(
|
||||
mode: PomodoroMode.work,
|
||||
remainingSeconds: 1500,
|
||||
totalSeconds: 1500,
|
||||
@ -53,7 +53,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('showTimer shows Start action when paused', () async {
|
||||
final state = PomodoroState(
|
||||
const state = PomodoroState(
|
||||
mode: PomodoroMode.shortBreak,
|
||||
remainingSeconds: 120,
|
||||
totalSeconds: 300,
|
||||
@ -68,7 +68,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('showTimer replaces previous notification', () async {
|
||||
final state = PomodoroState(
|
||||
const state = PomodoroState(
|
||||
mode: PomodoroMode.work,
|
||||
remainingSeconds: 1500,
|
||||
totalSeconds: 1500,
|
||||
@ -96,9 +96,8 @@ void main() {
|
||||
|
||||
test('handles unparsable gdbus output gracefully', () async {
|
||||
final stubService = NotificationService(
|
||||
runProcess: (exec, args) async {
|
||||
return ProcessResult(0, 0, 'unexpected output', '');
|
||||
},
|
||||
runProcess: (exec, args) async =>
|
||||
ProcessResult(0, 0, 'unexpected output', ''),
|
||||
);
|
||||
|
||||
final state = PomodoroState.initial();
|
||||
@ -192,15 +191,38 @@ void main() {
|
||||
|
||||
errorService.dispose();
|
||||
});
|
||||
|
||||
test('cancel handles CloseNotification error', () async {
|
||||
final cancelErrorService = NotificationService(
|
||||
runProcess: (exec, args) async {
|
||||
if (args.contains(
|
||||
'org.freedesktop.Notifications.CloseNotification',
|
||||
)) {
|
||||
throw const OSError('close failed');
|
||||
}
|
||||
return ProcessResult(0, 0, '(uint32 42,)', '');
|
||||
},
|
||||
);
|
||||
|
||||
final state = PomodoroState.initial();
|
||||
await cancelErrorService.showTimer(state: state);
|
||||
expect(cancelErrorService.currentId, 42);
|
||||
|
||||
// Should not throw; error is caught internally.
|
||||
await cancelErrorService.cancel();
|
||||
expect(cancelErrorService.currentId, 0);
|
||||
|
||||
cancelErrorService.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('progressBar', () {
|
||||
test('returns empty bar at 0%', () {
|
||||
expect(NotificationService.progressBar(0.0), '░' * 20);
|
||||
expect(NotificationService.progressBar(0), '░' * 20);
|
||||
});
|
||||
|
||||
test('returns full bar at 100%', () {
|
||||
expect(NotificationService.progressBar(1.0), '█' * 20);
|
||||
expect(NotificationService.progressBar(1), '█' * 20);
|
||||
});
|
||||
|
||||
test('returns half bar at 50%', () {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/services/notification_service.dart';
|
||||
import 'package:pomodoro_app/services/pomodoro_timer.dart';
|
||||
import 'package:pomodoro_app/services/sound_service.dart';
|
||||
|
||||
/// A controllable fake timer for testing.
|
||||
class FakeTimerController {
|
||||
@ -41,8 +44,8 @@ void main() {
|
||||
late FakeTimerController fakeController;
|
||||
|
||||
Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
|
||||
fakeController = FakeTimerController();
|
||||
fakeController._callback = callback;
|
||||
fakeController = FakeTimerController()
|
||||
.._callback = callback;
|
||||
return _FakeTimer(fakeController);
|
||||
}
|
||||
|
||||
@ -86,24 +89,25 @@ void main() {
|
||||
});
|
||||
|
||||
test('does nothing if already running', () {
|
||||
timer.start();
|
||||
final stateAfterFirstStart = timer.state;
|
||||
final stateAfterFirstStart = (timer..start()).state;
|
||||
timer.start(); // second call
|
||||
expect(timer.state, stateAfterFirstStart);
|
||||
});
|
||||
|
||||
test('notifies listeners', () {
|
||||
var notified = false;
|
||||
timer.addListener(() => notified = true);
|
||||
timer.start();
|
||||
timer
|
||||
..addListener(() => notified = true)
|
||||
..start();
|
||||
expect(notified, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('pause()', () {
|
||||
test('sets isRunning to false', () {
|
||||
timer.start();
|
||||
timer.pause();
|
||||
timer
|
||||
..start()
|
||||
..pause();
|
||||
expect(timer.state.isRunning, false);
|
||||
});
|
||||
|
||||
@ -115,8 +119,9 @@ void main() {
|
||||
|
||||
test('preserves remaining time', () {
|
||||
timer.start();
|
||||
fakeController.tick(); // -1s
|
||||
fakeController.tick(); // -1s
|
||||
fakeController
|
||||
..tick() // -1s
|
||||
..tick(); // -1s
|
||||
timer.pause();
|
||||
expect(timer.state.remainingSeconds, 58);
|
||||
});
|
||||
@ -133,8 +138,9 @@ void main() {
|
||||
timer.start();
|
||||
var count = 0;
|
||||
timer.addListener(() => count++);
|
||||
fakeController.tick();
|
||||
fakeController.tick();
|
||||
fakeController
|
||||
..tick()
|
||||
..tick();
|
||||
expect(count, 2);
|
||||
});
|
||||
});
|
||||
@ -193,8 +199,9 @@ void main() {
|
||||
group('reset()', () {
|
||||
test('resets to full duration', () {
|
||||
timer.start();
|
||||
fakeController.tick();
|
||||
fakeController.tick();
|
||||
fakeController
|
||||
..tick()
|
||||
..tick();
|
||||
timer.reset();
|
||||
expect(timer.state.remainingSeconds, 60);
|
||||
expect(timer.state.isRunning, false);
|
||||
@ -219,14 +226,16 @@ void main() {
|
||||
});
|
||||
|
||||
test('skips from break to work', () {
|
||||
timer.skip(); // work -> short break
|
||||
timer.skip(); // short break -> work
|
||||
timer
|
||||
..skip() // work -> short break
|
||||
..skip(); // short break -> work
|
||||
expect(timer.state.mode, PomodoroMode.work);
|
||||
});
|
||||
|
||||
test('stops the timer when skipping', () {
|
||||
timer.start();
|
||||
timer.skip();
|
||||
timer
|
||||
..start()
|
||||
..skip();
|
||||
expect(timer.state.isRunning, false);
|
||||
});
|
||||
});
|
||||
@ -234,15 +243,15 @@ void main() {
|
||||
group('dispose()', () {
|
||||
test('cancels internal timer', () {
|
||||
// Create a separate timer so tearDown does not double-dispose.
|
||||
final disposableTimer = PomodoroTimer(
|
||||
PomodoroTimer(
|
||||
workMinutes: 1,
|
||||
shortBreakMinutes: 1,
|
||||
longBreakMinutes: 2,
|
||||
pomodorosPerCycle: 2,
|
||||
timerFactory: fakeTimerFactory,
|
||||
);
|
||||
disposableTimer.start();
|
||||
disposableTimer.dispose();
|
||||
)
|
||||
..start()
|
||||
..dispose();
|
||||
expect(fakeController.isActive, false);
|
||||
});
|
||||
});
|
||||
@ -259,8 +268,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('switches back to pomodoro', () {
|
||||
timer.switchStyle(TimerStyle.ultraradian);
|
||||
timer.switchStyle(TimerStyle.pomodoro);
|
||||
timer
|
||||
..switchStyle(TimerStyle.ultraradian)
|
||||
..switchStyle(TimerStyle.pomodoro);
|
||||
expect(timer.timerStyle, TimerStyle.pomodoro);
|
||||
expect(timer.state.remainingSeconds, 25 * 60);
|
||||
expect(timer.state.totalSeconds, 25 * 60);
|
||||
@ -288,8 +298,9 @@ void main() {
|
||||
|
||||
test('notifies listeners', () {
|
||||
var notified = false;
|
||||
timer.addListener(() => notified = true);
|
||||
timer.switchStyle(TimerStyle.ultraradian);
|
||||
timer
|
||||
..addListener(() => notified = true)
|
||||
..switchStyle(TimerStyle.ultraradian);
|
||||
expect(notified, true);
|
||||
});
|
||||
|
||||
@ -316,7 +327,7 @@ void main() {
|
||||
var notified = false;
|
||||
timer.addListener(() => notified = true);
|
||||
|
||||
final remoteState = PomodoroState(
|
||||
const remoteState = PomodoroState(
|
||||
mode: PomodoroMode.shortBreak,
|
||||
remainingSeconds: 200,
|
||||
totalSeconds: 300,
|
||||
@ -334,7 +345,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('starts local ticking when remote state is running', () {
|
||||
final remoteState = PomodoroState(
|
||||
const remoteState = PomodoroState(
|
||||
mode: PomodoroMode.work,
|
||||
remainingSeconds: 500,
|
||||
totalSeconds: 600,
|
||||
@ -363,4 +374,109 @@ void main() {
|
||||
expect(timer.state.isRunning, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('Session complete with services', () {
|
||||
late List<String> playedSounds;
|
||||
late List<_Call> notifyCalls;
|
||||
late SoundService soundService;
|
||||
late NotificationService notificationService;
|
||||
late PomodoroTimer timerWithServices;
|
||||
|
||||
setUp(() {
|
||||
playedSounds = [];
|
||||
notifyCalls = [];
|
||||
|
||||
soundService = SoundService(
|
||||
playCallback: (assetPath) async => playedSounds.add(assetPath),
|
||||
);
|
||||
|
||||
notificationService = NotificationService(
|
||||
runProcess: (exec, args) async {
|
||||
notifyCalls.add(_Call(exec, args));
|
||||
return ProcessResult(0, 0, '(uint32 1,)', '');
|
||||
},
|
||||
);
|
||||
|
||||
timerWithServices = PomodoroTimer(
|
||||
workMinutes: 1,
|
||||
shortBreakMinutes: 1,
|
||||
longBreakMinutes: 1,
|
||||
pomodorosPerCycle: 2,
|
||||
timerFactory: fakeTimerFactory,
|
||||
soundService: soundService,
|
||||
notificationService: notificationService,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
timerWithServices.dispose();
|
||||
});
|
||||
|
||||
test('calls sound and notification services on session complete', () {
|
||||
timerWithServices.start();
|
||||
for (var i = 0; i < 60; i++) {
|
||||
fakeController.tick();
|
||||
}
|
||||
|
||||
expect(playedSounds, isNotEmpty);
|
||||
// showSessionComplete was called (the Notify call after the initial
|
||||
// showTimer from start).
|
||||
final sessionCompleteCall = notifyCalls.where(
|
||||
(c) => c.args.any((a) => a.contains('complete!')),
|
||||
);
|
||||
expect(sessionCompleteCall, isNotEmpty);
|
||||
});
|
||||
|
||||
test('long break completes and transitions to work', () {
|
||||
// Complete 2 work sessions to trigger long break.
|
||||
timerWithServices.start();
|
||||
for (var i = 0; i < 60; i++) {
|
||||
fakeController.tick();
|
||||
}
|
||||
expect(timerWithServices.state.mode, PomodoroMode.shortBreak);
|
||||
|
||||
timerWithServices.skip();
|
||||
expect(timerWithServices.state.mode, PomodoroMode.work);
|
||||
|
||||
timerWithServices.start();
|
||||
for (var i = 0; i < 60; i++) {
|
||||
fakeController.tick();
|
||||
}
|
||||
expect(timerWithServices.state.mode, PomodoroMode.longBreak);
|
||||
|
||||
// Now complete the long break.
|
||||
timerWithServices.start();
|
||||
for (var i = 0; i < 60; i++) {
|
||||
fakeController.tick();
|
||||
}
|
||||
expect(timerWithServices.state.mode, PomodoroMode.work);
|
||||
});
|
||||
|
||||
test('notification updates at 30-second intervals', () {
|
||||
timerWithServices.start();
|
||||
notifyCalls.clear();
|
||||
|
||||
// Tick 30 times so remainingSeconds goes from 60 to 30.
|
||||
for (var i = 0; i < 30; i++) {
|
||||
fakeController.tick();
|
||||
}
|
||||
expect(timerWithServices.state.remainingSeconds, 30);
|
||||
|
||||
// At 30 seconds remaining (divisible by 30), a notification update
|
||||
// should have been sent.
|
||||
final timerUpdates = notifyCalls.where(
|
||||
(c) => c.args.any(
|
||||
(a) => a.contains('org.freedesktop.Notifications.Notify'),
|
||||
),
|
||||
);
|
||||
expect(timerUpdates, isNotEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Captured call for the mock process runner.
|
||||
class _Call {
|
||||
_Call(this.executable, this.args);
|
||||
final String executable;
|
||||
final List<String> args;
|
||||
}
|
||||
|
||||
@ -1,7 +1,34 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/services/sound_service.dart';
|
||||
|
||||
/// A minimal fake [AudioPlayer] for testing the production path.
|
||||
///
|
||||
/// Uses [implements] + [noSuchMethod] to avoid calling the real
|
||||
/// AudioPlayer constructor which requires platform bindings.
|
||||
class _FakeAudioPlayer implements AudioPlayer {
|
||||
bool playCalled = false;
|
||||
|
||||
@override
|
||||
Future<void> play(
|
||||
Source source, {
|
||||
double? volume,
|
||||
double? balance,
|
||||
AudioContext? ctx,
|
||||
Duration? position,
|
||||
PlayerMode? mode,
|
||||
}) async {
|
||||
playCalled = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SoundService', () {
|
||||
late List<String> playedAssets;
|
||||
@ -59,5 +86,56 @@ void main() {
|
||||
);
|
||||
expect(playedAssets, isEmpty);
|
||||
});
|
||||
|
||||
test('handles playback error gracefully', () async {
|
||||
final errorService = SoundService(
|
||||
playCallback: (assetPath) async {
|
||||
throw Exception('audio error');
|
||||
},
|
||||
);
|
||||
|
||||
// Should not throw.
|
||||
await errorService.playTransitionSound(
|
||||
completedMode: PomodoroMode.work,
|
||||
nextMode: PomodoroMode.shortBreak,
|
||||
);
|
||||
|
||||
errorService.dispose();
|
||||
});
|
||||
|
||||
test('uses player factory for production path', () async {
|
||||
final fakePlayer = _FakeAudioPlayer();
|
||||
|
||||
final factoryService = SoundService(
|
||||
playerFactory: () => fakePlayer,
|
||||
);
|
||||
|
||||
await factoryService.playTransitionSound(
|
||||
completedMode: PomodoroMode.work,
|
||||
nextMode: PomodoroMode.shortBreak,
|
||||
);
|
||||
|
||||
expect(fakePlayer.playCalled, true);
|
||||
factoryService.dispose();
|
||||
});
|
||||
|
||||
test('disposes previous player on subsequent plays', () async {
|
||||
final factoryService = SoundService(
|
||||
playerFactory: _FakeAudioPlayer.new,
|
||||
);
|
||||
|
||||
await factoryService.playTransitionSound(
|
||||
completedMode: PomodoroMode.work,
|
||||
nextMode: PomodoroMode.shortBreak,
|
||||
);
|
||||
|
||||
// Play again — should dispose the previous player.
|
||||
await factoryService.playTransitionSound(
|
||||
completedMode: PomodoroMode.shortBreak,
|
||||
nextMode: PomodoroMode.work,
|
||||
);
|
||||
|
||||
factoryService.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/services/sync_service.dart';
|
||||
@ -15,7 +16,7 @@ class FakeDatagramSocket implements RawDatagramSocket {
|
||||
|
||||
@override
|
||||
int send(List<int> buffer, InternetAddress address, int port) {
|
||||
sentMessages.add(SentDatagram(buffer, address, port));
|
||||
sentMessages.add(SentDatagram(buffer));
|
||||
return buffer.length;
|
||||
}
|
||||
|
||||
@ -25,27 +26,31 @@ class FakeDatagramSocket implements RawDatagramSocket {
|
||||
/// Simulates receiving a datagram.
|
||||
void injectDatagram(List<int> data, InternetAddress address, int port) {
|
||||
_pendingDatagram = Datagram(
|
||||
data as dynamic,
|
||||
Uint8List.fromList(data),
|
||||
address,
|
||||
port,
|
||||
);
|
||||
_controller.add(RawSocketEvent.read);
|
||||
}
|
||||
|
||||
/// Simulates a socket error.
|
||||
void injectError(Object error) {
|
||||
_controller.addError(error);
|
||||
}
|
||||
|
||||
@override
|
||||
StreamSubscription<RawSocketEvent> listen(
|
||||
void Function(RawSocketEvent)? onData, {
|
||||
Function? onError,
|
||||
void Function()? onDone,
|
||||
bool? cancelOnError,
|
||||
}) {
|
||||
return _controller.stream.listen(
|
||||
onData,
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError ?? false,
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
_controller.stream.listen(
|
||||
onData,
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError ?? false,
|
||||
);
|
||||
|
||||
@override
|
||||
void joinMulticast(InternetAddress group, [NetworkInterface? interface_]) {}
|
||||
@ -62,16 +67,16 @@ class FakeDatagramSocket implements RawDatagramSocket {
|
||||
}
|
||||
|
||||
class SentDatagram {
|
||||
SentDatagram(this.data, this.address, this.port);
|
||||
SentDatagram(this.data);
|
||||
final List<int> data;
|
||||
final InternetAddress address;
|
||||
final int port;
|
||||
|
||||
Map<String, dynamic> get decoded =>
|
||||
jsonDecode(utf8.decode(data)) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('SyncService', () {
|
||||
late FakeDatagramSocket fakeSocket;
|
||||
late SyncService service;
|
||||
@ -113,8 +118,9 @@ void main() {
|
||||
final decoded = fakeSocket.sentMessages.first.decoded;
|
||||
expect(decoded['deviceId'], 'test-device-1');
|
||||
expect(decoded['action'], 'start');
|
||||
expect(decoded['state']['mode'], 'work');
|
||||
expect(decoded['state']['remainingSeconds'], 25 * 60);
|
||||
final stateMap = decoded['state'] as Map<String, dynamic>;
|
||||
expect(stateMap['mode'], 'work');
|
||||
expect(stateMap['remainingSeconds'], 25 * 60);
|
||||
});
|
||||
|
||||
test('ignores own messages', () async {
|
||||
@ -195,12 +201,9 @@ void main() {
|
||||
|
||||
test('heartbeat sends periodic state', () async {
|
||||
final state = PomodoroState.initial();
|
||||
service.startHeartbeat(() => state);
|
||||
|
||||
// Wait for at least one heartbeat interval.
|
||||
// Note: In tests, Timer.periodic fires based on the test framework.
|
||||
// We just verify it doesn't crash and can be stopped.
|
||||
service.stopHeartbeat();
|
||||
service
|
||||
..startHeartbeat(() => state)
|
||||
..stopHeartbeat();
|
||||
});
|
||||
});
|
||||
|
||||
@ -256,4 +259,168 @@ void main() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncService error paths', () {
|
||||
test('start catches socket bind failure', () async {
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'err-device',
|
||||
socketFactory: (host, port) async {
|
||||
throw const SocketException('bind failed');
|
||||
},
|
||||
);
|
||||
|
||||
// Should not throw.
|
||||
await service.start();
|
||||
expect(service.isActive, false);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test('broadcast catches send failure', () async {
|
||||
final throwingSocket = _ThrowingSendSocket();
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'send-err',
|
||||
socketFactory: (host, port) async => throwingSocket,
|
||||
);
|
||||
await service.start();
|
||||
|
||||
// broadcast should not throw.
|
||||
service.broadcast(PomodoroState.initial(), 'test');
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test('heartbeat callback fires and sends broadcast', () async {
|
||||
final fakeSocket = FakeDatagramSocket();
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'hb-device',
|
||||
socketFactory: (host, port) async => fakeSocket,
|
||||
);
|
||||
await service.start();
|
||||
|
||||
fakeSocket.sentMessages.clear();
|
||||
service.startHeartbeat(
|
||||
PomodoroState.initial,
|
||||
interval: const Duration(milliseconds: 1),
|
||||
);
|
||||
|
||||
// Allow the periodic timer to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
// The heartbeat should have fired at least once.
|
||||
expect(fakeSocket.sentMessages, isNotEmpty);
|
||||
|
||||
service.stopHeartbeat();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test('wake send failure is caught', () async {
|
||||
// _sendWake is called during start(). If socket.send throws for
|
||||
// the wake port, it should be caught.
|
||||
final throwOnWakeSocket = _ThrowingSendSocket();
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'wake-err',
|
||||
socketFactory: (host, port) async => throwOnWakeSocket,
|
||||
);
|
||||
|
||||
// start() calls _sendWake() which will throw — should be caught.
|
||||
await service.start();
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test('socket stream error invokes onError handler', () async {
|
||||
final fakeSocket = FakeDatagramSocket();
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'stream-err',
|
||||
socketFactory: (host, port) async => fakeSocket,
|
||||
);
|
||||
await service.start();
|
||||
|
||||
// Inject an error into the stream — should not crash.
|
||||
fakeSocket.injectError(const SocketException('stream error'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncService multicast lock (Android paths)', () {
|
||||
const channel = MethodChannel('pomodoro_multicast_lock');
|
||||
|
||||
test('acquires and releases lock when isAndroid is true', () async {
|
||||
// No handler registered → MissingPluginException caught internally.
|
||||
final fakeSocket = FakeDatagramSocket();
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'android-device',
|
||||
isAndroid: true,
|
||||
socketFactory: (host, port) async => fakeSocket,
|
||||
);
|
||||
|
||||
await service.start();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test('handles non-MissingPluginException in acquire', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'acquire') {
|
||||
throw PlatformException(code: 'ERROR', message: 'lock failed');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final fakeSocket = FakeDatagramSocket();
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'android-err-acquire',
|
||||
isAndroid: true,
|
||||
socketFactory: (host, port) async => fakeSocket,
|
||||
);
|
||||
|
||||
await service.start();
|
||||
await service.dispose();
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('handles non-MissingPluginException in release', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'release') {
|
||||
throw PlatformException(code: 'ERROR', message: 'release failed');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
final fakeSocket = FakeDatagramSocket();
|
||||
final service = SyncService(
|
||||
onStateReceived: (_, _) {},
|
||||
deviceId: 'android-err-release',
|
||||
isAndroid: true,
|
||||
socketFactory: (host, port) async => fakeSocket,
|
||||
);
|
||||
|
||||
await service.start();
|
||||
await service.dispose();
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A fake socket that throws on every [send] call.
|
||||
class _ThrowingSendSocket extends FakeDatagramSocket {
|
||||
@override
|
||||
int send(List<int> buffer, InternetAddress address, int port) {
|
||||
throw const SocketException('send failed');
|
||||
}
|
||||
}
|
||||
|
||||
14
pomodoro_app/test/theme/pomodoro_theme_test.dart
Normal file
14
pomodoro_app/test/theme/pomodoro_theme_test.dart
Normal file
@ -0,0 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
||||
import 'package:pomodoro_app/theme/pomodoro_theme.dart';
|
||||
|
||||
void main() {
|
||||
group('PomodoroTheme.colorForMode', () {
|
||||
test('returns longBreakColor for longBreak', () {
|
||||
expect(
|
||||
PomodoroTheme.colorForMode(PomodoroMode.longBreak),
|
||||
PomodoroTheme.longBreakColor,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
1
python_pkg/fm24_searcher/__init__.py
Normal file
1
python_pkg/fm24_searcher/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""FM24 Database Searcher — hybrid binary + HTML player database tool."""
|
||||
24
python_pkg/fm24_searcher/__main__.py
Normal file
24
python_pkg/fm24_searcher/__main__.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Entry point for FM24 Database Searcher.
|
||||
|
||||
Supports two modes:
|
||||
- GUI (default): ``python -m python_pkg.fm24_searcher``
|
||||
- CLI dump: ``python -m python_pkg.fm24_searcher --dump``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from python_pkg.fm24_searcher.cli import run_dump
|
||||
from python_pkg.fm24_searcher.gui import main
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
"""Dispatch to GUI or CLI based on arguments."""
|
||||
if "--dump" in sys.argv:
|
||||
raise SystemExit(run_dump())
|
||||
main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
468
python_pkg/fm24_searcher/binary_parser.py
Normal file
468
python_pkg/fm24_searcher/binary_parser.py
Normal file
@ -0,0 +1,468 @@
|
||||
r"""Binary parser for FM24 database files.
|
||||
|
||||
Extracts player names, DOB, personality bytes from
|
||||
people_db.dat and save game files. CA/PA require HTML
|
||||
import; the binary DB does not expose current/potential
|
||||
ability as readable values. Nationality is stored as a
|
||||
uint32 at +13 after the name end, not a uint16 at +9.
|
||||
|
||||
File format summary:
|
||||
- Outer wrapper: 8-byte magic + zstd compressed payload
|
||||
- Magic: \\x03\\x01tad.\\xef\\r
|
||||
- Payload: 8-byte inner header + uint32 record_count + records
|
||||
- Multi-frame files (client_db, server_db, saves): \\x02\\x01fmf.
|
||||
container with multiple zstd frames
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import datetime
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import zstandard
|
||||
|
||||
from python_pkg.fm24_searcher.models import Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
TAD_MAGIC = b"\x03\x01tad.\xef\r"
|
||||
FMF_MAGIC = b"\x02\x01fmf."
|
||||
ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
|
||||
|
||||
# Record separator found between simple records.
|
||||
REC_SEP = b"\x05\x00\x00\x00\x00"
|
||||
|
||||
MAX_OUTPUT = 500 * 1024 * 1024 # 500 MB decompression limit
|
||||
|
||||
# DOB validation bounds.
|
||||
_MIN_YEAR = 1930
|
||||
_MAX_YEAR = 2012
|
||||
_MAX_DAY_OF_YEAR = 366
|
||||
|
||||
# Name length bounds.
|
||||
_BOUNDARY_MIN_NAME_LEN = 3
|
||||
_EXTRACT_MIN_NAME_LEN = 3
|
||||
_MAX_NAME_LEN = 80
|
||||
|
||||
# Attribute bounds.
|
||||
_MAX_PERSONALITY_VAL = 20
|
||||
|
||||
# --- Attribute block constants ---
|
||||
_ATTR_BLOCK_SIZE = 63
|
||||
_ATTR_ZERO_RANGE = range(20, 26)
|
||||
_ATTR_ZERO_SINGLES = frozenset({40, 41, 42})
|
||||
_ATTR_MIN_NONZERO = 30
|
||||
_ATTR_SEARCH_WINDOW = 1500
|
||||
_SIX_ZEROS = b"\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
# Byte position → attribute name (36 confirmed visible attributes).
|
||||
ATTR_BLOCK_MAP: dict[int, str] = {
|
||||
9: "Crossing",
|
||||
10: "Technique",
|
||||
11: "Balance",
|
||||
12: "Heading",
|
||||
13: "Free Kick",
|
||||
14: "Marking",
|
||||
15: "Off The Ball",
|
||||
16: "Vision",
|
||||
17: "Decisions",
|
||||
18: "Tackling",
|
||||
19: "Flair",
|
||||
26: "Finishing",
|
||||
27: "First Touch",
|
||||
29: "Positioning",
|
||||
31: "Dribbling",
|
||||
32: "Passing",
|
||||
36: "Corners",
|
||||
37: "Leadership",
|
||||
38: "Work Rate",
|
||||
39: "Long Throws",
|
||||
43: "Anticipation",
|
||||
45: "Strength",
|
||||
46: "Teamwork",
|
||||
47: "Penalty Taking",
|
||||
48: "Jumping Reach",
|
||||
49: "Long Shots",
|
||||
51: "Agility",
|
||||
52: "Bravery",
|
||||
53: "Composure",
|
||||
54: "Aggression",
|
||||
55: "Acceleration",
|
||||
58: "Stamina",
|
||||
59: "Natural Fitness",
|
||||
60: "Determination",
|
||||
61: "Pace",
|
||||
62: "Concentration",
|
||||
}
|
||||
|
||||
|
||||
def _is_valid_attr_block(block: bytes) -> bool:
|
||||
"""Check whether *block* (63 bytes) matches the attribute pattern."""
|
||||
if any(b > _MAX_PERSONALITY_VAL for b in block):
|
||||
return False
|
||||
if any(block[j] != 0 for j in _ATTR_ZERO_RANGE):
|
||||
return False
|
||||
if any(block[j] != 0 for j in _ATTR_ZERO_SINGLES):
|
||||
return False
|
||||
return sum(1 for b in block if b > 0) >= _ATTR_MIN_NONZERO
|
||||
|
||||
|
||||
def _find_all_attr_blocks(data: bytes) -> tuple[list[int], list[list[int]]]:
|
||||
"""Locate every 63-byte attribute block in *data*.
|
||||
|
||||
Phase 1: collect all candidate block starts at C speed
|
||||
using ``bytes.find`` on the six-zero anchor at positions
|
||||
20-25. Phase 2: validate all candidates at once with
|
||||
numpy vectorised operations.
|
||||
|
||||
Returns ``(offsets, values)`` where both lists are sorted
|
||||
by offset and have the same length.
|
||||
"""
|
||||
# Phase 1: C-speed scan for the six-zero anchor.
|
||||
candidates: list[int] = []
|
||||
pos = 0
|
||||
data_len = len(data)
|
||||
while True:
|
||||
idx = data.find(_SIX_ZEROS, pos)
|
||||
if idx < 0:
|
||||
break
|
||||
block_start = idx - 20
|
||||
if block_start >= 0 and block_start + _ATTR_BLOCK_SIZE <= data_len:
|
||||
candidates.append(block_start)
|
||||
pos = idx + 1
|
||||
if not candidates:
|
||||
return [], []
|
||||
|
||||
# Phase 2: bulk numpy validation of all candidate blocks.
|
||||
arr = np.frombuffer(data, dtype=np.uint8)
|
||||
bs = np.array(candidates, dtype=np.int32)
|
||||
# sliding_window_view creates a zero-copy view; shape (N-62, 63).
|
||||
windows = np.lib.stride_tricks.sliding_window_view(arr, _ATTR_BLOCK_SIZE)
|
||||
# Guard: discard any index beyond the last valid window.
|
||||
valid_idx = bs[bs < len(windows)]
|
||||
blocks = windows[valid_idx] # copies only the selected rows
|
||||
|
||||
# All bytes must be <= _MAX_PERSONALITY_VAL (20).
|
||||
cond1 = (blocks <= _MAX_PERSONALITY_VAL).all(axis=1)
|
||||
# Positions 40-42 must be zero (positions 20-25 are
|
||||
# guaranteed zero by the six-zero anchor construction).
|
||||
cond3 = (blocks[:, [40, 41, 42]] == 0).all(axis=1)
|
||||
# At least _ATTR_MIN_NONZERO (30) bytes must be non-zero.
|
||||
cond4 = (blocks > 0).sum(axis=1) >= _ATTR_MIN_NONZERO
|
||||
|
||||
valid_mask = cond1 & cond3 & cond4
|
||||
offsets: list[int] = [int(x) for x in valid_idx[valid_mask]]
|
||||
values: list[list[int]] = [[int(b) for b in row] for row in blocks[valid_mask]]
|
||||
return offsets, values
|
||||
|
||||
|
||||
def _attrs_from_block(block: list[int]) -> dict[str, int]:
|
||||
"""Map a raw 63-byte block to ``{attr_name: value}``."""
|
||||
return {name: block[pos] for pos, name in ATTR_BLOCK_MAP.items() if block[pos] > 0}
|
||||
|
||||
|
||||
def _enrich_with_attributes(
|
||||
data: bytes,
|
||||
players: list[Player],
|
||||
progress_cb: Callable[[str, int], None] | None = None,
|
||||
) -> None:
|
||||
"""Find attribute blocks and assign them to nearby players.
|
||||
|
||||
Each player's ``uid`` is its prefix-byte offset in *data*.
|
||||
The nearest valid block within *_ATTR_SEARCH_WINDOW* bytes
|
||||
before that offset is picked.
|
||||
"""
|
||||
if progress_cb:
|
||||
progress_cb("Indexing attribute blocks...", 96)
|
||||
block_offsets, block_values = _find_all_attr_blocks(data)
|
||||
if not block_offsets:
|
||||
return
|
||||
|
||||
if progress_cb:
|
||||
progress_cb(
|
||||
f"Assigning attributes ({len(block_offsets)} blocks)...",
|
||||
97,
|
||||
)
|
||||
for player in players:
|
||||
idx = bisect.bisect_right(block_offsets, player.uid) - 1
|
||||
if idx < 0:
|
||||
continue
|
||||
if player.uid - block_offsets[idx] > _ATTR_SEARCH_WINDOW:
|
||||
continue
|
||||
player.attributes = _attrs_from_block(block_values[idx])
|
||||
|
||||
|
||||
def _decompress_single(raw: bytes) -> bytes:
|
||||
"""Decompress a TAD-magic .dat file (single zstd frame)."""
|
||||
if raw[:8] != TAD_MAGIC:
|
||||
msg = f"Expected TAD magic, got {raw[:8]!r}"
|
||||
raise ValueError(msg)
|
||||
dctx = zstandard.ZstdDecompressor()
|
||||
result: bytes = dctx.decompress(raw[8:], max_output_size=MAX_OUTPUT)
|
||||
return result
|
||||
|
||||
|
||||
def _decompress_multiframe(raw: bytes) -> list[bytes]:
|
||||
"""Decompress a multi-frame FMF container.
|
||||
|
||||
Returns list of decompressed frame payloads.
|
||||
"""
|
||||
dctx = zstandard.ZstdDecompressor()
|
||||
frames: list[bytes] = []
|
||||
idx = 0
|
||||
while True:
|
||||
pos = raw.find(ZSTD_MAGIC, idx)
|
||||
if pos < 0:
|
||||
break
|
||||
try:
|
||||
data = dctx.decompress(
|
||||
raw[pos:],
|
||||
max_output_size=MAX_OUTPUT,
|
||||
)
|
||||
frames.append(data)
|
||||
except zstandard.ZstdError:
|
||||
pass
|
||||
idx = pos + 4
|
||||
return frames
|
||||
|
||||
|
||||
def decompress_file(filepath: Path) -> bytes | list[bytes]:
|
||||
"""Auto-detect format and decompress.
|
||||
|
||||
Single frame → bytes, multi-frame → list[bytes].
|
||||
"""
|
||||
raw = filepath.read_bytes()
|
||||
if raw[:8] == TAD_MAGIC:
|
||||
return _decompress_single(raw)
|
||||
if FMF_MAGIC in raw[:20]:
|
||||
return _decompress_multiframe(raw)
|
||||
msg = f"Unknown file format: {filepath}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _dob_from_bytes(data: bytes, offset: int) -> str:
|
||||
"""Extract DOB as ISO string from 4 bytes.
|
||||
|
||||
Format: uint16 day-of-year + uint16 year.
|
||||
"""
|
||||
day_of_year = struct.unpack_from("<H", data, offset)[0]
|
||||
year = struct.unpack_from("<H", data, offset + 2)[0]
|
||||
if not (_MIN_YEAR <= year <= _MAX_YEAR and 1 <= day_of_year <= _MAX_DAY_OF_YEAR):
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.date(year, 1, 1) + datetime.timedelta(
|
||||
days=day_of_year - 1,
|
||||
)
|
||||
return dt.isoformat()
|
||||
except (ValueError, OverflowError):
|
||||
return ""
|
||||
|
||||
|
||||
def _find_name_boundaries(
|
||||
data: bytes,
|
||||
name_pos: int,
|
||||
) -> tuple[str, int, int] | None:
|
||||
"""Find name boundaries from a position in the data.
|
||||
|
||||
Given a name fragment position, find the uint32 length
|
||||
prefix and return (full_name, start_offset, end_offset).
|
||||
"""
|
||||
for back in range(_MAX_NAME_LEN):
|
||||
off = name_pos - back - 4
|
||||
if off < 0:
|
||||
continue
|
||||
name_len = struct.unpack_from("<I", data, off)[0]
|
||||
if not (_BOUNDARY_MIN_NAME_LEN <= name_len <= _MAX_NAME_LEN):
|
||||
continue
|
||||
ns = off + 4
|
||||
ne = ns + name_len
|
||||
if ns <= name_pos < ne:
|
||||
candidate = data[ns:ne]
|
||||
try:
|
||||
name = candidate.decode("utf-8")
|
||||
if name.isprintable():
|
||||
return (name, off, ne)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _is_valid_name(data: bytes, offset: int, length: int) -> str:
|
||||
"""Try to decode a name at offset with given length.
|
||||
|
||||
Returns the name string if valid, empty string otherwise.
|
||||
"""
|
||||
end = offset + length
|
||||
if end > len(data):
|
||||
return ""
|
||||
candidate = data[offset:end]
|
||||
try:
|
||||
name = candidate.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return ""
|
||||
# First and last chars must be alphabetic; names do not
|
||||
# start or end with punctuation or symbols like '<'.
|
||||
if not (name[0].isalpha() and name[-1].isalpha()):
|
||||
return ""
|
||||
if not all(c.isprintable() or c in " -'." for c in name):
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def _try_extract_player(
|
||||
data: bytes,
|
||||
prefix_offset: int,
|
||||
) -> tuple[Player, int] | None:
|
||||
"""Try to extract a player record starting at prefix_offset.
|
||||
|
||||
Returns (Player, name_end_offset) or None if not a valid
|
||||
record.
|
||||
"""
|
||||
if prefix_offset + 30 > len(data):
|
||||
return None
|
||||
# Prefix byte should be 0x00.
|
||||
if data[prefix_offset] != 0x00:
|
||||
return None
|
||||
name_len = struct.unpack_from(
|
||||
"<I",
|
||||
data,
|
||||
prefix_offset + 1,
|
||||
)[0]
|
||||
if not (_EXTRACT_MIN_NAME_LEN <= name_len <= _MAX_NAME_LEN):
|
||||
return None
|
||||
name_start = prefix_offset + 5
|
||||
name = _is_valid_name(data, name_start, name_len)
|
||||
if not name:
|
||||
return None
|
||||
ne = name_start + name_len
|
||||
if ne + 25 > len(data):
|
||||
return None
|
||||
|
||||
dob = _dob_from_bytes(data, ne)
|
||||
|
||||
# 8 personality bytes at +17 from name end.
|
||||
personality = list(data[ne + 17 : ne + 25])
|
||||
valid_pers = all(0 <= p <= _MAX_PERSONALITY_VAL for p in personality)
|
||||
|
||||
player = Player(
|
||||
uid=prefix_offset,
|
||||
name=name,
|
||||
date_of_birth=dob,
|
||||
personality=personality if valid_pers else [],
|
||||
source="binary",
|
||||
)
|
||||
return (player, ne)
|
||||
|
||||
|
||||
def _pass1_separator_walk(
|
||||
data: bytes,
|
||||
players: list[Player],
|
||||
seen_offsets: set[int],
|
||||
) -> None:
|
||||
"""Walk separator-delimited records (short/retired players)."""
|
||||
idx = 12
|
||||
while True:
|
||||
pos = data.find(REC_SEP, idx)
|
||||
if pos < 0:
|
||||
break
|
||||
prefix_off = pos + 5
|
||||
result = _try_extract_player(data, prefix_off)
|
||||
if result:
|
||||
player, ne = result
|
||||
if prefix_off not in seen_offsets:
|
||||
seen_offsets.add(prefix_off)
|
||||
players.append(player)
|
||||
idx = ne
|
||||
else:
|
||||
idx = pos + 1
|
||||
|
||||
|
||||
def _pass2_regex_scan(
|
||||
data: bytes,
|
||||
players: list[Player],
|
||||
seen_offsets: set[int],
|
||||
progress_cb: Callable[[str, int], None] | None = None,
|
||||
) -> None:
|
||||
"""Scan for name patterns to find active player records."""
|
||||
pattern = re.compile(
|
||||
b"\\x00[\\x02-\\x50]\\x00\\x00\\x00[A-Z\\xc0-\\xff]",
|
||||
)
|
||||
matches = list(pattern.finditer(data))
|
||||
total_matches = len(matches)
|
||||
for i, m in enumerate(matches):
|
||||
prefix_off = m.start()
|
||||
if prefix_off in seen_offsets:
|
||||
continue
|
||||
result = _try_extract_player(data, prefix_off)
|
||||
if result:
|
||||
player, _ne = result
|
||||
has_dob = bool(player.date_of_birth)
|
||||
has_multiword = " " in player.name
|
||||
if (has_dob or has_multiword) and prefix_off not in seen_offsets:
|
||||
seen_offsets.add(prefix_off)
|
||||
players.append(player)
|
||||
if progress_cb and i % 50000 == 0 and total_matches > 0:
|
||||
pct = 30 + int(65 * i / total_matches)
|
||||
progress_cb(
|
||||
f"Scanning... {len(players)} players found",
|
||||
pct,
|
||||
)
|
||||
|
||||
|
||||
def parse_people_db(
|
||||
filepath: Path,
|
||||
progress_cb: Callable[[str, int], None] | None = None,
|
||||
) -> list[Player]:
|
||||
"""Parse people_db.dat and extract player records.
|
||||
|
||||
Args:
|
||||
filepath: Path to people_db.dat.
|
||||
progress_cb: Optional callback(stage_msg, percent).
|
||||
|
||||
Uses a two-pass approach:
|
||||
1. Walk separator-delimited records (short/retired).
|
||||
2. Scan for name patterns to find active player records.
|
||||
"""
|
||||
if progress_cb:
|
||||
progress_cb("Decompressing database...", 0)
|
||||
data = _decompress_single(filepath.read_bytes())
|
||||
if progress_cb:
|
||||
progress_cb("Decompressed, scanning records...", 15)
|
||||
struct.unpack_from("<I", data, 8)[0]
|
||||
|
||||
players: list[Player] = []
|
||||
seen_offsets: set[int] = set()
|
||||
|
||||
_pass1_separator_walk(data, players, seen_offsets)
|
||||
|
||||
if progress_cb:
|
||||
progress_cb(
|
||||
f"Pass 1 done ({len(players)} found), scanning full database...",
|
||||
30,
|
||||
)
|
||||
|
||||
_pass2_regex_scan(data, players, seen_offsets, progress_cb)
|
||||
|
||||
_enrich_with_attributes(data, players, progress_cb)
|
||||
|
||||
if progress_cb:
|
||||
progress_cb(
|
||||
f"Done — {len(players)} players loaded",
|
||||
100,
|
||||
)
|
||||
return players
|
||||
|
||||
|
||||
def search_players(
|
||||
players: list[Player],
|
||||
query: str,
|
||||
) -> list[Player]:
|
||||
"""Simple name-based search."""
|
||||
query_lower = query.lower()
|
||||
return [p for p in players if query_lower in p.name.lower()]
|
||||
221
python_pkg/fm24_searcher/cli.py
Normal file
221
python_pkg/fm24_searcher/cli.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""CLI dump mode for FM24 Database Searcher.
|
||||
|
||||
Outputs player data as plain text so LLMs can inspect
|
||||
and verify extracted values.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m python_pkg.fm24_searcher --dump
|
||||
python -m python_pkg.fm24_searcher --dump --search Messi
|
||||
python -m python_pkg.fm24_searcher --dump --limit 20
|
||||
python -m python_pkg.fm24_searcher --dump --attrs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.fm24_searcher.binary_parser import (
|
||||
parse_people_db,
|
||||
search_players,
|
||||
)
|
||||
from python_pkg.fm24_searcher.models import ALL_VISIBLE_ATTRS, Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
# Default path to FM24 people database.
|
||||
_DEFAULT_DB = (
|
||||
Path.home()
|
||||
/ ".local/share/Steam/steamapps/common"
|
||||
/ "Football Manager 2024/data/database/db"
|
||||
/ "2400/2400_fm/people_db.dat"
|
||||
)
|
||||
|
||||
_DEFAULT_LIMIT = 50
|
||||
|
||||
|
||||
def _format_player_attrs(player: Player) -> list[str]:
|
||||
"""Format the attributes section for a player."""
|
||||
if not player.attributes:
|
||||
return [" (no attribute block found)"]
|
||||
lines = [" Attributes:"]
|
||||
lines += [
|
||||
f" {attr}: {val}"
|
||||
for attr in ALL_VISIBLE_ATTRS
|
||||
if (val := player.attributes.get(attr, 0)) > 0
|
||||
]
|
||||
missing = [a for a in ALL_VISIBLE_ATTRS if a not in player.attributes]
|
||||
if missing:
|
||||
lines.append(f" Missing attrs: {', '.join(missing)}")
|
||||
return lines
|
||||
|
||||
|
||||
_OPTIONAL_FIELDS = [
|
||||
("DOB", "date_of_birth"),
|
||||
("CA", "current_ability"),
|
||||
("PA", "potential_ability"),
|
||||
("Nationality", "nationality"),
|
||||
("Club", "club"),
|
||||
("Position", "position"),
|
||||
("Personality bytes", "personality"),
|
||||
]
|
||||
|
||||
|
||||
def _format_player(player: Player, *, show_attrs: bool = False) -> str:
|
||||
"""Format one player as a multi-line text block."""
|
||||
lines = [f"=== {player.name} ==="]
|
||||
lines += [
|
||||
f" {label}: {getattr(player, field)}"
|
||||
for label, field in _OPTIONAL_FIELDS
|
||||
if getattr(player, field)
|
||||
]
|
||||
if show_attrs:
|
||||
lines.extend(_format_player_attrs(player))
|
||||
lines.append(f" Source: {player.source}")
|
||||
lines.append(f" UID (byte offset): {player.uid}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_tsv_header(*, show_attrs: bool) -> str:
|
||||
"""Build TSV header line."""
|
||||
cols = ["Name", "DOB", "CA", "PA", "Personality", "UID"]
|
||||
if show_attrs:
|
||||
cols.extend(ALL_VISIBLE_ATTRS)
|
||||
return "\t".join(cols)
|
||||
|
||||
|
||||
def _format_tsv_row(player: Player, *, show_attrs: bool) -> str:
|
||||
"""Format one player as a TSV row."""
|
||||
cols = [
|
||||
player.name,
|
||||
player.date_of_birth,
|
||||
str(player.current_ability) if player.current_ability else "",
|
||||
str(player.potential_ability) if player.potential_ability else "",
|
||||
",".join(str(p) for p in player.personality),
|
||||
str(player.uid),
|
||||
]
|
||||
if show_attrs:
|
||||
for attr in ALL_VISIBLE_ATTRS:
|
||||
val = player.attributes.get(attr, 0)
|
||||
cols.append(str(val) if val > 0 else "")
|
||||
return "\t".join(cols)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser for CLI mode."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="fm24_searcher",
|
||||
description="FM24 Database Searcher — CLI dump mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dump",
|
||||
action="store_true",
|
||||
help="Enable CLI dump mode (text output, no GUI)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--search",
|
||||
type=str,
|
||||
default="",
|
||||
help="Filter players by name substring",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=_DEFAULT_LIMIT,
|
||||
help=f"Max number of players to show (default {_DEFAULT_LIMIT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--attrs",
|
||||
action="store_true",
|
||||
help="Include all visible attributes in output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tsv",
|
||||
action="store_true",
|
||||
help="Output as tab-separated values (machine-readable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
type=str,
|
||||
default="",
|
||||
help="Path to people_db.dat (overrides default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with-attrs-only",
|
||||
action="store_true",
|
||||
help="Only show players that have attribute blocks",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stats",
|
||||
action="store_true",
|
||||
help="Show summary statistics about the loaded database",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _print_stats(players: list[Player]) -> None:
|
||||
"""Print summary statistics about loaded players."""
|
||||
total = len(players)
|
||||
sum(1 for p in players if p.date_of_birth)
|
||||
with_attrs = sum(1 for p in players if p.attributes)
|
||||
sum(1 for p in players if p.current_ability)
|
||||
if with_attrs > 0:
|
||||
sum(len(p.attributes) for p in players) / with_attrs
|
||||
if total == 0:
|
||||
return
|
||||
if with_attrs > 0:
|
||||
pass
|
||||
# Attribute coverage
|
||||
attr_counts: dict[str, int] = {}
|
||||
for p in players:
|
||||
for attr in p.attributes:
|
||||
attr_counts[attr] = attr_counts.get(attr, 0) + 1
|
||||
if attr_counts:
|
||||
for attr in ALL_VISIBLE_ATTRS:
|
||||
count = attr_counts.get(attr, 0)
|
||||
pct = 100 * count / with_attrs if with_attrs else 0
|
||||
"*" * int(pct / 5)
|
||||
|
||||
|
||||
def run_dump(argv: Sequence[str] | None = None) -> int:
|
||||
"""Execute CLI dump mode. Returns exit code."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.dump:
|
||||
return 1
|
||||
|
||||
db_path = Path(args.db) if args.db else _DEFAULT_DB
|
||||
if not db_path.exists():
|
||||
return 2
|
||||
|
||||
def progress(msg: str, pct: int) -> None:
|
||||
pass
|
||||
|
||||
players = parse_people_db(db_path, progress_cb=progress)
|
||||
|
||||
if args.search:
|
||||
players = search_players(players, args.search)
|
||||
|
||||
if args.with_attrs_only:
|
||||
players = [p for p in players if p.attributes]
|
||||
|
||||
if args.stats:
|
||||
_print_stats(players)
|
||||
return 0
|
||||
|
||||
# Apply limit
|
||||
limited = players[: args.limit]
|
||||
|
||||
# Output
|
||||
if args.tsv:
|
||||
for _p in limited:
|
||||
pass
|
||||
else:
|
||||
for _p in limited:
|
||||
pass
|
||||
|
||||
return 0
|
||||
71
python_pkg/fm24_searcher/find_attrs_v2_results.txt
Normal file
71
python_pkg/fm24_searcher/find_attrs_v2_results.txt
Normal file
@ -0,0 +1,71 @@
|
||||
Loaded 78 frames
|
||||
|
||||
Frame 4: Messi in records [2897, 63738]
|
||||
Frame 4: Haaland in records []
|
||||
|
||||
============================================================
|
||||
Frame 3: 83837 records x 196 bytes
|
||||
|
||||
--- Record 2897 in Frame 3 ---
|
||||
[ 0: 50] [0, 226, 7, 182, 0, 232, 7, 1, 224, 13, 102, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 1, 182, 0, 231, 7, 182, 0, 232, 7, 1, 93, 179, 15, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 6, 182, 0, 231]
|
||||
[ 50:100] [7, 182, 0, 232, 7, 1, 173, 181, 32, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 8, 182, 0, 231, 7, 182, 0, 232, 7, 1, 174, 217, 7, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 11, 182, 0, 231, 7, 182]
|
||||
[100:150] [0, 232, 7, 1, 188, 138, 6, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 19, 182, 0, 231, 7, 182, 0, 232, 7, 1, 201, 59, 5, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 4, 182, 0, 231, 7, 182, 0, 232]
|
||||
[150:196] [7, 1, 120, 21, 13, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 3, 182, 0, 231, 7, 182, 0, 232, 7, 1, 161, 40, 9, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 238, 27, 123, 98, 0, 0]
|
||||
Attr value matches: 26/196
|
||||
|
||||
--- Record 63738 in Frame 3 ---
|
||||
[ 0: 50] [0, 0, 255, 255, 255, 255, 0, 0, 0, 1, 188, 241, 0, 0, 0, 0, 0, 0, 0, 0, 0, 195, 240, 0, 0, 218, 18, 60, 4, 218, 18, 60, 4, 0, 255, 255, 255, 255, 174, 0, 0, 0, 174, 0, 0, 0, 174, 0, 0, 0]
|
||||
[ 50:100] [73, 38, 0, 0, 0, 0, 0, 0, 0, 0, 20, 0, 0, 0, 70, 67, 32, 80, 114, 111, 103, 114, 101, 115, 32, 66, 101, 114, 100, 121, 99, 104, 105, 118, 20, 0, 0, 0, 70, 67, 32, 80, 114, 111, 103, 114, 101, 115, 32, 66]
|
||||
[100:150] [101, 114, 100, 121, 99, 104, 105, 118, 3, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 108, 7, 1, 0, 108, 7, 1, 0, 108, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
[150:196] [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 63, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 1, 189, 241, 0, 0, 0, 0, 0, 0, 0, 0, 0, 196, 240, 0, 0, 231, 18, 60, 4, 231, 18]
|
||||
Attr value matches: 13/196
|
||||
|
||||
============================================================
|
||||
Frame 28: 84085 records x 104 bytes
|
||||
|
||||
--- Record 2897 in Frame 28 ---
|
||||
[ 0: 50] [255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 0, 253, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 195, 5, 0, 0, 164, 6, 0, 0, 164, 6, 0, 0, 10]
|
||||
[ 50:100] [0, 9, 124, 21, 139, 23, 0, 0, 138, 23, 0, 0, 255, 255, 255, 255, 151, 1, 0, 0, 2, 7, 0, 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 19, 0, 13, 246, 3, 0, 49, 174, 4, 0, 220, 220, 4, 0, 201, 230, 4]
|
||||
[100:104] [0, 85, 234, 4]
|
||||
Attr value matches: 12/104
|
||||
|
||||
--- Record 63738 in Frame 28 ---
|
||||
[ 0: 50] [19, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 255, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 0]
|
||||
[ 50:100] [226, 241, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 219, 242, 0, 0, 231, 43, 60, 4, 231, 43, 60, 4, 10, 0, 0, 100, 0, 213, 2, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 4, 130, 0, 0, 255, 255, 255]
|
||||
[100:104] [255, 255, 255, 255]
|
||||
Attr value matches: 5/104
|
||||
|
||||
============================================================
|
||||
Frame 29: 84085 records x 82 bytes
|
||||
|
||||
--- Record 2897 in Frame 29 ---
|
||||
[ 0: 50] [81, 11, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0, 16, 39, 0, 0, 0, 0, 108, 7, 0, 1, 0, 244, 1, 244, 1, 244, 1, 1, 0, 108, 7, 1, 0, 108, 7, 1, 0, 108, 7, 1, 0, 108, 7, 1, 0, 108, 7, 0]
|
||||
[ 50: 82] [0, 0, 0, 0, 0, 0, 0, 1, 0, 108, 7, 255, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0]
|
||||
Attr value matches: 9/82
|
||||
|
||||
--- Record 63738 in Frame 29 ---
|
||||
[ 0: 50] [250, 248, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0, 16, 39, 0, 0, 0, 0, 108, 7, 0, 1, 0, 244, 1, 244, 1, 244, 1, 1, 0, 108, 7, 1, 0, 108, 7, 1, 0, 108, 7, 1, 0, 108, 7, 1, 0, 108, 7, 0]
|
||||
[ 50: 82] [0, 0, 0, 0, 0, 0, 0, 1, 0, 108, 7, 255, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0]
|
||||
Attr value matches: 9/82
|
||||
|
||||
============================================================
|
||||
Cross-reference check: same indices in Frame 3 and 4
|
||||
|
||||
Frame3[2897] first 30: [0, 226, 7, 182, 0, 232, 7, 1, 224, 13, 102, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 1, 1, 182, 0, 231, 7, 182, 0, 232]
|
||||
Frame4[2897] first 30: [220, 2, 208, 249, 220, 2, 1, 0, 1, 0, 108, 7, 127, 2, 0, 0, 91, 107, 65, 0, 0, 0, 1, 0, 0, 0, 0, 255, 255, 255]
|
||||
|
||||
Frame3[63738] first 30: [0, 0, 255, 255, 255, 255, 0, 0, 0, 1, 188, 241, 0, 0, 0, 0, 0, 0, 0, 0, 0, 195, 240, 0, 0, 218, 18, 60, 4, 218]
|
||||
Frame4[63738] first 30: [255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0]
|
||||
|
||||
============================================================
|
||||
Frame 28: 84085 records x 104 bytes
|
||||
|
||||
Frame3[2897] - looking for scaled attributes:
|
||||
Value 200 at: []
|
||||
Value 30 at: []
|
||||
x10 matches: 2 - [(152, 120, 12), (177, 40, 4)]
|
||||
|
||||
Frame3[63738] - looking for scaled attributes:
|
||||
Value 200 at: []
|
||||
Value 30 at: []
|
||||
x10 matches: 6 - [(64, 70, 7), (67, 80, 8), (78, 100, 10), (88, 70, 7), (91, 80, 8), (102, 100, 10)]
|
||||
1164
python_pkg/fm24_searcher/gui.py
Normal file
1164
python_pkg/fm24_searcher/gui.py
Normal file
File diff suppressed because it is too large
Load Diff
311
python_pkg/fm24_searcher/html_parser.py
Normal file
311
python_pkg/fm24_searcher/html_parser.py
Normal file
@ -0,0 +1,311 @@
|
||||
"""HTML import parser for FM24 exported views.
|
||||
|
||||
FM24 allows exporting search/scout views via Ctrl+P (Printing).
|
||||
The result is an HTML file containing player data in tables.
|
||||
This module parses that HTML to extract player attributes.
|
||||
|
||||
Supported: the default FM24 HTML export format with
|
||||
<table> containing player rows and attribute columns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
import html
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.fm24_searcher.models import ALL_VISIBLE_ATTRS, GOALKEEPER_ATTRS, Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# Common FM attribute header normalizations.
|
||||
_HEADER_MAP: dict[str, str] = {
|
||||
"cor": "Corners",
|
||||
"cro": "Crossing",
|
||||
"dri": "Dribbling",
|
||||
"fin": "Finishing",
|
||||
"fir": "First Touch",
|
||||
"fre": "Free Kick",
|
||||
"hea": "Heading",
|
||||
"lon": "Long Shots",
|
||||
"l th": "Long Throws",
|
||||
"mar": "Marking",
|
||||
"pas": "Passing",
|
||||
"pen": "Penalty Taking",
|
||||
"tck": "Tackling",
|
||||
"tec": "Technique",
|
||||
"agg": "Aggression",
|
||||
"ant": "Anticipation",
|
||||
"bra": "Bravery",
|
||||
"cmp": "Composure",
|
||||
"cnt": "Concentration",
|
||||
"dec": "Decisions",
|
||||
"det": "Determination",
|
||||
"fla": "Flair",
|
||||
"ldr": "Leadership",
|
||||
"otb": "Off The Ball",
|
||||
"pos": "Positioning",
|
||||
"tea": "Teamwork",
|
||||
"vis": "Vision",
|
||||
"wor": "Work Rate",
|
||||
"acc": "Acceleration",
|
||||
"agi": "Agility",
|
||||
"bal": "Balance",
|
||||
"jum": "Jumping Reach",
|
||||
"nat": "Natural Fitness",
|
||||
"pac": "Pace",
|
||||
"sta": "Stamina",
|
||||
"str": "Strength",
|
||||
# Goalkeeper
|
||||
"aer": "Aerial Reach",
|
||||
"cmd": "Command of Area",
|
||||
"com": "Communication",
|
||||
"ecc": "Eccentricity",
|
||||
"han": "Handling",
|
||||
"kic": "Kicking",
|
||||
"1v1": "One on Ones",
|
||||
"pun": "Punching (Tendency)",
|
||||
"ref": "Reflexes",
|
||||
"rus": "Rushing Out (Tendency)",
|
||||
"thr": "Throwing",
|
||||
# Alternative spellings
|
||||
"wk r": "Work Rate",
|
||||
"work rate": "Work Rate",
|
||||
"corners": "Corners",
|
||||
"crossing": "Crossing",
|
||||
"dribbling": "Dribbling",
|
||||
"finishing": "Finishing",
|
||||
"first touch": "First Touch",
|
||||
"heading": "Heading",
|
||||
"long shots": "Long Shots",
|
||||
"long throws": "Long Throws",
|
||||
"marking": "Marking",
|
||||
"passing": "Passing",
|
||||
"tackling": "Tackling",
|
||||
"technique": "Technique",
|
||||
}
|
||||
|
||||
# Build reverse lookup: normalized attr name → canonical.
|
||||
_ALL_ATTRS_LOWER = {a.lower(): a for a in ALL_VISIBLE_ATTRS + GOALKEEPER_ATTRS}
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
"""Remove HTML tags and decode entities."""
|
||||
text = _TAG_RE.sub("", text)
|
||||
text = html.unescape(text)
|
||||
return _WS_RE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def _normalize_header(raw: str) -> str | None:
|
||||
"""Map an HTML column header to a canonical attribute name."""
|
||||
clean = _strip_html(raw).strip().lower()
|
||||
# Direct lookup.
|
||||
if clean in _HEADER_MAP:
|
||||
return _HEADER_MAP[clean]
|
||||
if clean in _ALL_ATTRS_LOWER:
|
||||
return _ALL_ATTRS_LOWER[clean]
|
||||
# Truncated header: try first 3 chars.
|
||||
short = clean[:3]
|
||||
if short in _HEADER_MAP:
|
||||
return _HEADER_MAP[short]
|
||||
return None
|
||||
|
||||
|
||||
def _extract_tables(html_content: str) -> list[list[list[str]]]:
|
||||
"""Parse HTML tables into a list of row lists.
|
||||
|
||||
Each row is a list of cell strings. Returns list of tables.
|
||||
"""
|
||||
tables: list[list[list[str]]] = []
|
||||
table_re = re.compile(
|
||||
r"<table[^>]*>(.*?)</table>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
row_re = re.compile(
|
||||
r"<tr[^>]*>(.*?)</tr>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
cell_re = re.compile(
|
||||
r"<t[hd][^>]*>(.*?)</t[hd]>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
for table_match in table_re.finditer(html_content):
|
||||
rows: list[list[str]] = []
|
||||
for row_match in row_re.finditer(table_match.group(1)):
|
||||
cells = [
|
||||
_strip_html(c.group(1)) for c in cell_re.finditer(row_match.group(1))
|
||||
]
|
||||
if cells:
|
||||
rows.append(cells)
|
||||
if rows:
|
||||
tables.append(rows)
|
||||
return tables
|
||||
|
||||
|
||||
_MIN_TABLE_ROWS = 2
|
||||
_MIN_ATTR_VAL = 1
|
||||
_MAX_ATTR_VAL = 20
|
||||
|
||||
# Map from lowercase header text to _ColMap field name.
|
||||
_HDR_FIELD: dict[str, str] = {
|
||||
"name": "name",
|
||||
"player": "name",
|
||||
"club": "club",
|
||||
"team": "club",
|
||||
"nat": "nat",
|
||||
"nationality": "nat",
|
||||
"position": "pos",
|
||||
"pos": "pos",
|
||||
"ca": "ca",
|
||||
"ability": "ca",
|
||||
"pa": "pa",
|
||||
"potential": "pa",
|
||||
"value": "value",
|
||||
"val": "value",
|
||||
"wage": "wage",
|
||||
}
|
||||
|
||||
# Map from _ColMap field name to Player attribute name.
|
||||
_FIELD_ATTR: list[tuple[str, str]] = [
|
||||
("club", "club"),
|
||||
("nat", "nationality"),
|
||||
("pos", "position"),
|
||||
("value", "value"),
|
||||
("wage", "wage"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ColMap:
|
||||
"""Column index mapping from parsed HTML table headers."""
|
||||
|
||||
name: int | None = None
|
||||
club: int | None = None
|
||||
nat: int | None = None
|
||||
pos: int | None = None
|
||||
ca: int | None = None
|
||||
pa: int | None = None
|
||||
value: int | None = None
|
||||
wage: int | None = None
|
||||
attrs: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _build_col_map(headers: list[str]) -> _ColMap:
|
||||
"""Build column index mapping from table header cells."""
|
||||
cols = _ColMap()
|
||||
for i, hdr in enumerate(headers):
|
||||
h = hdr.strip().lower()
|
||||
if field := _HDR_FIELD.get(h):
|
||||
setattr(cols, field, i)
|
||||
elif attr_name := _normalize_header(hdr):
|
||||
cols.attrs[i] = attr_name
|
||||
return cols
|
||||
|
||||
|
||||
def _apply_attr(player: Player, attr_name: str, val_str: str) -> None:
|
||||
"""Parse val_str and set an attribute on player if value is in range."""
|
||||
if "-" in val_str and val_str[0].isdigit():
|
||||
val_str = val_str.split("-", maxsplit=1)[0]
|
||||
with contextlib.suppress(ValueError):
|
||||
val = int(val_str)
|
||||
if _MIN_ATTR_VAL <= val <= _MAX_ATTR_VAL:
|
||||
if attr_name in ALL_VISIBLE_ATTRS:
|
||||
player.attributes[attr_name] = val
|
||||
else:
|
||||
player.gk_attributes[attr_name] = val
|
||||
|
||||
|
||||
def _parse_player_row(row: list[str], cols: _ColMap) -> Player | None:
|
||||
"""Parse one data row into a Player; returns None if row is invalid."""
|
||||
if cols.name is None or len(row) <= cols.name:
|
||||
return None
|
||||
name = row[cols.name].strip()
|
||||
if not name:
|
||||
return None
|
||||
player = Player(name=name, source="html")
|
||||
|
||||
def _get(col: int | None) -> str | None:
|
||||
return row[col].strip() if col is not None and col < len(row) else None
|
||||
|
||||
for col_field, attr in _FIELD_ATTR:
|
||||
if val := _get(getattr(cols, col_field)):
|
||||
setattr(player, attr, val)
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
if raw := _get(cols.ca):
|
||||
player.current_ability = int(raw)
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
if raw := _get(cols.pa):
|
||||
player.potential_ability = int(raw)
|
||||
for col_idx, attr_name in cols.attrs.items():
|
||||
if col_idx < len(row):
|
||||
_apply_attr(player, attr_name, row[col_idx].strip())
|
||||
return player
|
||||
|
||||
|
||||
def parse_html_export(filepath: Path) -> list[Player]:
|
||||
"""Parse an FM24 HTML export file into Player objects.
|
||||
|
||||
Looks for tables where column headers map to known FM
|
||||
attributes. The 'Name' column is required.
|
||||
"""
|
||||
content = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
all_players: list[Player] = []
|
||||
for table in _extract_tables(content):
|
||||
if len(table) < _MIN_TABLE_ROWS:
|
||||
continue
|
||||
cols = _build_col_map(table[0])
|
||||
if cols.name is None:
|
||||
continue
|
||||
for row in table[1:]:
|
||||
player = _parse_player_row(row, cols)
|
||||
if player is not None:
|
||||
all_players.append(player)
|
||||
return all_players
|
||||
|
||||
|
||||
def merge_players(
|
||||
binary_players: list[Player],
|
||||
html_players: list[Player],
|
||||
) -> list[Player]:
|
||||
"""Merge binary-parsed data with HTML-imported attributes.
|
||||
|
||||
Matches by name (case-insensitive). Binary provides
|
||||
DOB/CA/personality; HTML provides visible attributes.
|
||||
"""
|
||||
html_by_name: dict[str, Player] = {}
|
||||
for p in html_players:
|
||||
html_by_name[p.name.lower()] = p
|
||||
|
||||
merged: list[Player] = []
|
||||
matched_names: set[str] = set()
|
||||
|
||||
for bp in binary_players:
|
||||
key = bp.name.lower()
|
||||
if key in html_by_name:
|
||||
hp = html_by_name[key]
|
||||
bp.attributes = hp.attributes
|
||||
bp.gk_attributes = hp.gk_attributes
|
||||
bp.club = hp.club or bp.club
|
||||
bp.nationality = hp.nationality or bp.nationality
|
||||
bp.position = hp.position or bp.position
|
||||
bp.value = hp.value or bp.value
|
||||
bp.wage = hp.wage or bp.wage
|
||||
if hp.current_ability > 0:
|
||||
bp.current_ability = hp.current_ability
|
||||
if hp.potential_ability > 0:
|
||||
bp.potential_ability = hp.potential_ability
|
||||
bp.source = "merged"
|
||||
matched_names.add(key)
|
||||
merged.append(bp)
|
||||
|
||||
# Add HTML-only players not matched.
|
||||
merged.extend(hp for hp in html_players if hp.name.lower() not in matched_names)
|
||||
|
||||
return merged
|
||||
147
python_pkg/fm24_searcher/models.py
Normal file
147
python_pkg/fm24_searcher/models.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""Data model for FM24 players."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# FM24 visible attribute names grouped by category.
|
||||
TECHNICAL_ATTRS: list[str] = [
|
||||
"Corners",
|
||||
"Crossing",
|
||||
"Dribbling",
|
||||
"Finishing",
|
||||
"First Touch",
|
||||
"Free Kick",
|
||||
"Heading",
|
||||
"Long Shots",
|
||||
"Long Throws",
|
||||
"Marking",
|
||||
"Passing",
|
||||
"Penalty Taking",
|
||||
"Tackling",
|
||||
"Technique",
|
||||
]
|
||||
|
||||
MENTAL_ATTRS: list[str] = [
|
||||
"Aggression",
|
||||
"Anticipation",
|
||||
"Bravery",
|
||||
"Composure",
|
||||
"Concentration",
|
||||
"Decisions",
|
||||
"Determination",
|
||||
"Flair",
|
||||
"Leadership",
|
||||
"Off The Ball",
|
||||
"Positioning",
|
||||
"Teamwork",
|
||||
"Vision",
|
||||
"Work Rate",
|
||||
]
|
||||
|
||||
PHYSICAL_ATTRS: list[str] = [
|
||||
"Acceleration",
|
||||
"Agility",
|
||||
"Balance",
|
||||
"Jumping Reach",
|
||||
"Natural Fitness",
|
||||
"Pace",
|
||||
"Stamina",
|
||||
"Strength",
|
||||
]
|
||||
|
||||
GOALKEEPER_ATTRS: list[str] = [
|
||||
"Aerial Reach",
|
||||
"Command of Area",
|
||||
"Communication",
|
||||
"Eccentricity",
|
||||
"First Touch (GK)",
|
||||
"Handling",
|
||||
"Kicking",
|
||||
"One on Ones",
|
||||
"Passing (GK)",
|
||||
"Punching (Tendency)",
|
||||
"Reflexes",
|
||||
"Rushing Out (Tendency)",
|
||||
"Throwing",
|
||||
]
|
||||
|
||||
ALL_VISIBLE_ATTRS: list[str] = TECHNICAL_ATTRS + MENTAL_ATTRS + PHYSICAL_ATTRS
|
||||
|
||||
|
||||
@dataclass
|
||||
class Player:
|
||||
"""A single FM24 player record."""
|
||||
|
||||
# Identity (from binary or HTML).
|
||||
uid: int = 0
|
||||
name: str = ""
|
||||
|
||||
# Biographical.
|
||||
date_of_birth: str = "" # ISO format YYYY-MM-DD
|
||||
nationality: str = ""
|
||||
club: str = ""
|
||||
position: str = ""
|
||||
|
||||
# Ability ratings (from binary — may be approximate).
|
||||
current_ability: int = 0
|
||||
potential_ability: int = 0
|
||||
|
||||
# Hidden personality bytes (from binary).
|
||||
personality: list[int] = field(default_factory=list)
|
||||
|
||||
# Visible attributes (1-20 scale). Key = attribute name.
|
||||
attributes: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# Goalkeeper attributes.
|
||||
gk_attributes: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# Monetary values.
|
||||
value: str = ""
|
||||
wage: str = ""
|
||||
|
||||
# Data source for traceability.
|
||||
source: str = "" # "binary", "html", or "merged"
|
||||
|
||||
def get_attr(self, name: str) -> int:
|
||||
"""Get an attribute value by name, 0 if missing."""
|
||||
return self.attributes.get(name, 0)
|
||||
|
||||
def weighted_score(
|
||||
self,
|
||||
weights: dict[str, float],
|
||||
) -> float:
|
||||
"""Compute weighted attribute score for scouting."""
|
||||
total = 0.0
|
||||
weight_sum = 0.0
|
||||
for attr_name, weight in weights.items():
|
||||
val = self.get_attr(attr_name)
|
||||
if val > 0:
|
||||
total += val * weight
|
||||
weight_sum += weight
|
||||
if weight_sum == 0:
|
||||
return 0.0
|
||||
return total / weight_sum
|
||||
|
||||
def matches_filter(
|
||||
self,
|
||||
min_attrs: dict[str, int] | None = None,
|
||||
min_ca: int | None = None,
|
||||
position_filter: str | None = None,
|
||||
nationality_filter: str | None = None,
|
||||
club_filter: str | None = None,
|
||||
) -> bool:
|
||||
"""Check if this player matches all given filters."""
|
||||
if min_attrs:
|
||||
for attr_name, min_val in min_attrs.items():
|
||||
if self.get_attr(attr_name) < min_val:
|
||||
return False
|
||||
if min_ca and self.current_ability < min_ca:
|
||||
return False
|
||||
if position_filter and position_filter.lower() not in (self.position.lower()):
|
||||
return False
|
||||
if nationality_filter and nationality_filter.lower() not in (
|
||||
self.nationality.lower()
|
||||
):
|
||||
return False
|
||||
return not (club_filter and club_filter.lower() not in self.club.lower())
|
||||
1
python_pkg/fm24_searcher/tests/__init__.py
Normal file
1
python_pkg/fm24_searcher/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for FM24 Database Searcher."""
|
||||
13
python_pkg/fm24_searcher/tests/conftest.py
Normal file
13
python_pkg/fm24_searcher/tests/conftest.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""Shared fixtures for FM24 searcher tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _offscreen_qt() -> None:
|
||||
"""Force offscreen Qt platform for headless testing."""
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
721
python_pkg/fm24_searcher/tests/test_binary_parser.py
Normal file
721
python_pkg/fm24_searcher/tests/test_binary_parser.py
Normal file
@ -0,0 +1,721 @@
|
||||
"""Tests for python_pkg.fm24_searcher.binary_parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import zstandard
|
||||
|
||||
from python_pkg.fm24_searcher.binary_parser import (
|
||||
ATTR_BLOCK_MAP,
|
||||
FMF_MAGIC,
|
||||
REC_SEP,
|
||||
TAD_MAGIC,
|
||||
ZSTD_MAGIC,
|
||||
_attrs_from_block,
|
||||
_decompress_multiframe,
|
||||
_decompress_single,
|
||||
_dob_from_bytes,
|
||||
_enrich_with_attributes,
|
||||
_find_all_attr_blocks,
|
||||
_find_name_boundaries,
|
||||
_is_valid_attr_block,
|
||||
_is_valid_name,
|
||||
_pass1_separator_walk,
|
||||
_pass2_regex_scan,
|
||||
_try_extract_player,
|
||||
decompress_file,
|
||||
parse_people_db,
|
||||
search_players,
|
||||
)
|
||||
from python_pkg.fm24_searcher.models import Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _zstd_compress(data: bytes) -> bytes:
|
||||
"""Compress data with zstd for test fixtures."""
|
||||
cctx = zstandard.ZstdCompressor()
|
||||
result: bytes = cctx.compress(data)
|
||||
return result
|
||||
|
||||
|
||||
def _make_tad(payload: bytes) -> bytes:
|
||||
"""Create a TAD-formatted blob."""
|
||||
return TAD_MAGIC + _zstd_compress(payload)
|
||||
|
||||
|
||||
def _make_player_record(
|
||||
name: str,
|
||||
*,
|
||||
day_of_year: int = 180,
|
||||
year: int = 1995,
|
||||
personality: bytes = b"\x0a\x0b\x0c\x0d\x0e\x0f\x10\x05",
|
||||
prefix: int = 0x00,
|
||||
) -> bytes:
|
||||
"""Build a minimal binary player record.
|
||||
|
||||
Layout: 0x00 + uint32 name_len + name_utf8 + DOB(4) +
|
||||
5 flag bytes + 2 bytes "nat_id" + 2 zeros + 4 bytes "ref" +
|
||||
8 personality bytes.
|
||||
"""
|
||||
name_bytes = name.encode("utf-8")
|
||||
rec = bytes([prefix])
|
||||
rec += struct.pack("<I", len(name_bytes))
|
||||
rec += name_bytes
|
||||
rec += struct.pack("<H", day_of_year) # DOB day
|
||||
rec += struct.pack("<H", year) # DOB year
|
||||
rec += b"\x07\x03\x0b\x06\x0c" # 5 flag bytes
|
||||
rec += b"\x8b\x00" # +9: uint16 nat_id (ignored)
|
||||
rec += b"\x00\x00" # +11-12: zeros
|
||||
rec += b"\x01\x03\x00\x00" # +13-16: ref_id (ignored)
|
||||
rec += personality # +17-24: 8 personality bytes
|
||||
return rec
|
||||
|
||||
|
||||
class TestDecompressSingle:
|
||||
"""_decompress_single tests."""
|
||||
|
||||
def test_valid(self) -> None:
|
||||
payload = b"hello world"
|
||||
raw = _make_tad(payload)
|
||||
assert _decompress_single(raw) == payload
|
||||
|
||||
def test_bad_magic(self) -> None:
|
||||
with pytest.raises(ValueError, match="Expected TAD magic"):
|
||||
_decompress_single(b"\x00" * 8 + b"data")
|
||||
|
||||
|
||||
class TestDecompressMultiframe:
|
||||
"""_decompress_multiframe tests."""
|
||||
|
||||
def test_multiple_frames(self) -> None:
|
||||
frame1 = _zstd_compress(b"frame1")
|
||||
frame2 = _zstd_compress(b"frame2")
|
||||
raw = b"header" + frame1 + b"gap" + frame2
|
||||
result = _decompress_multiframe(raw)
|
||||
assert b"frame1" in result
|
||||
assert b"frame2" in result
|
||||
|
||||
def test_no_frames(self) -> None:
|
||||
result = _decompress_multiframe(b"no zstd here")
|
||||
assert result == []
|
||||
|
||||
def test_corrupt_frame_skipped(self) -> None:
|
||||
# Put the magic bytes but no valid zstd data after.
|
||||
raw = ZSTD_MAGIC + b"\x00\x00\x00"
|
||||
result = _decompress_multiframe(raw)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestDecompressFile:
|
||||
"""decompress_file tests."""
|
||||
|
||||
def test_tad_file(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "test.dat"
|
||||
p.write_bytes(_make_tad(b"payload"))
|
||||
assert decompress_file(p) == b"payload"
|
||||
|
||||
def test_fmf_file(self, tmp_path: Path) -> None:
|
||||
frame = _zstd_compress(b"content")
|
||||
raw = FMF_MAGIC + b"\x00" * 10 + frame
|
||||
p = tmp_path / "test.dat"
|
||||
p.write_bytes(raw)
|
||||
result = decompress_file(p)
|
||||
assert isinstance(result, list)
|
||||
assert b"content" in result
|
||||
|
||||
def test_unknown_format(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "test.dat"
|
||||
p.write_bytes(b"\xff" * 20)
|
||||
with pytest.raises(ValueError, match="Unknown file format"):
|
||||
decompress_file(p)
|
||||
|
||||
|
||||
class TestDobFromBytes:
|
||||
"""_dob_from_bytes tests."""
|
||||
|
||||
def test_valid_dob(self) -> None:
|
||||
data = struct.pack("<HH", 180, 1995)
|
||||
assert _dob_from_bytes(data, 0) == "1995-06-29"
|
||||
|
||||
def test_jan_first(self) -> None:
|
||||
data = struct.pack("<HH", 1, 2000)
|
||||
assert _dob_from_bytes(data, 0) == "2000-01-01"
|
||||
|
||||
def test_dec_31(self) -> None:
|
||||
data = struct.pack("<HH", 365, 2000)
|
||||
assert _dob_from_bytes(data, 0) == "2000-12-30"
|
||||
|
||||
def test_invalid_year_below(self) -> None:
|
||||
data = struct.pack("<HH", 180, 1800)
|
||||
assert _dob_from_bytes(data, 0) == ""
|
||||
|
||||
def test_invalid_year_above(self) -> None:
|
||||
data = struct.pack("<HH", 180, 2020)
|
||||
assert _dob_from_bytes(data, 0) == ""
|
||||
|
||||
def test_invalid_day_zero(self) -> None:
|
||||
data = struct.pack("<HH", 0, 1995)
|
||||
assert _dob_from_bytes(data, 0) == ""
|
||||
|
||||
def test_invalid_day_too_large(self) -> None:
|
||||
data = struct.pack("<HH", 400, 1995)
|
||||
assert _dob_from_bytes(data, 0) == ""
|
||||
|
||||
def test_leap_year_day_366(self) -> None:
|
||||
data = struct.pack("<HH", 366, 2000)
|
||||
assert _dob_from_bytes(data, 0) == "2000-12-31"
|
||||
|
||||
def test_overflow_day_366_non_leap(self) -> None:
|
||||
# Day 366 in a non-leap year overflows to next year.
|
||||
data = struct.pack("<HH", 366, 1999)
|
||||
# timedelta(365) from Jan 1 1999 → Jan 1 2000.
|
||||
assert _dob_from_bytes(data, 0) == "2000-01-01"
|
||||
|
||||
def test_date_creation_raises(self) -> None:
|
||||
"""Cover except (ValueError, OverflowError) branch (lines 115-116)."""
|
||||
data = struct.pack("<HH", 180, 1995)
|
||||
with patch("python_pkg.fm24_searcher.binary_parser.datetime") as mock_dt:
|
||||
mock_dt.date.side_effect = OverflowError("forced")
|
||||
assert _dob_from_bytes(data, 0) == ""
|
||||
|
||||
def test_with_offset(self) -> None:
|
||||
prefix = b"\xff\xff"
|
||||
data = prefix + struct.pack("<HH", 1, 2005)
|
||||
assert _dob_from_bytes(data, 2) == "2005-01-01"
|
||||
|
||||
|
||||
class TestFindNameBoundaries:
|
||||
"""_find_name_boundaries tests."""
|
||||
|
||||
def test_found(self) -> None:
|
||||
name = "John Smith"
|
||||
name_bytes = name.encode("utf-8")
|
||||
data = b"\x00" + struct.pack("<I", len(name_bytes)) + name_bytes
|
||||
# name_pos = index of 'J' = 5
|
||||
result = _find_name_boundaries(data, 5)
|
||||
assert result is not None
|
||||
assert result[0] == name
|
||||
assert result[1] == 1 # offset of length prefix
|
||||
assert result[2] == 5 + len(name_bytes) # name end
|
||||
|
||||
def test_not_found(self) -> None:
|
||||
data = b"\xff" * 100
|
||||
assert _find_name_boundaries(data, 50) is None
|
||||
|
||||
def test_invalid_utf8(self) -> None:
|
||||
raw_name = b"\x80\x81\x82\x83"
|
||||
data = b"\x00" + struct.pack("<I", len(raw_name)) + raw_name
|
||||
assert _find_name_boundaries(data, 5) is None
|
||||
|
||||
def test_name_too_short(self) -> None:
|
||||
name = b"AB" # 2 bytes, below _BOUNDARY_MIN_NAME_LEN (3)
|
||||
data = b"\x00" + struct.pack("<I", len(name)) + name
|
||||
assert _find_name_boundaries(data, 5) is None
|
||||
|
||||
def test_offset_too_small(self) -> None:
|
||||
# name_pos at 0 means off = 0 - back - 4 < 0.
|
||||
assert _find_name_boundaries(b"\x00" * 10, 0) is None
|
||||
|
||||
def test_non_printable_name(self) -> None:
|
||||
name = "Test\x01Name"
|
||||
name_bytes = name.encode("utf-8")
|
||||
data = b"\x00" + struct.pack("<I", len(name_bytes)) + name_bytes
|
||||
assert _find_name_boundaries(data, 5) is None
|
||||
|
||||
def test_name_pos_outside_range(self) -> None:
|
||||
"""Valid name_len found but name_pos not in [ns, ne) (137→128)."""
|
||||
# Place valid length=5 at offset 10.
|
||||
data = b"\xff" * 10 + struct.pack("<I", 5) + b"Hello" + b"\xff" * 50
|
||||
# name_pos=30. At back=16, off=10. ns=14, ne=19. 14<=30<19? No.
|
||||
assert _find_name_boundaries(data, 30) is None
|
||||
|
||||
|
||||
class TestIsValidName:
|
||||
"""_is_valid_name tests."""
|
||||
|
||||
def test_valid(self) -> None:
|
||||
data = b"John Smith"
|
||||
assert _is_valid_name(data, 0, 10) == "John Smith"
|
||||
|
||||
def test_beyond_data(self) -> None:
|
||||
data = b"Short"
|
||||
assert _is_valid_name(data, 0, 100) == ""
|
||||
|
||||
def test_invalid_utf8(self) -> None:
|
||||
data = b"\x80\x81\x82"
|
||||
assert _is_valid_name(data, 0, 3) == ""
|
||||
|
||||
def test_no_alpha(self) -> None:
|
||||
data = b"123 456"
|
||||
assert _is_valid_name(data, 0, 7) == ""
|
||||
|
||||
def test_non_printable(self) -> None:
|
||||
data = b"Test\x00Name"
|
||||
assert _is_valid_name(data, 0, len(data)) == ""
|
||||
|
||||
def test_with_offset(self) -> None:
|
||||
data = b"\xff\xffJohn"
|
||||
assert _is_valid_name(data, 2, 4) == "John"
|
||||
|
||||
def test_trailing_non_alpha_rejected(self) -> None:
|
||||
"""Names ending with punctuation like '<' are rejected."""
|
||||
data = b"John Smith<"
|
||||
assert _is_valid_name(data, 0, len(data)) == ""
|
||||
|
||||
|
||||
class TestTryExtractPlayer:
|
||||
"""_try_extract_player tests."""
|
||||
|
||||
def test_valid_record(self) -> None:
|
||||
rec = _make_player_record("John Smith", day_of_year=180, year=1995)
|
||||
padding = b"\x00" * 10 # trailing space
|
||||
result = _try_extract_player(rec + padding, 0)
|
||||
assert result is not None
|
||||
player, _ne = result
|
||||
assert player.name == "John Smith"
|
||||
assert player.date_of_birth == "1995-06-29"
|
||||
assert player.uid == 0 # prefix_offset
|
||||
assert player.source == "binary"
|
||||
assert len(player.personality) == 8
|
||||
|
||||
def test_too_short(self) -> None:
|
||||
assert _try_extract_player(b"\x00" * 10, 0) is None
|
||||
|
||||
def test_bad_prefix(self) -> None:
|
||||
data = b"\x01" + b"\x00" * 50
|
||||
assert _try_extract_player(data, 0) is None
|
||||
|
||||
def test_name_too_short(self) -> None:
|
||||
# Name len = 1 (below _EXTRACT_MIN_NAME_LEN = 3).
|
||||
data = b"\x00" + struct.pack("<I", 1) + b"A" + b"\x00" * 50
|
||||
assert _try_extract_player(data, 0) is None
|
||||
|
||||
def test_name_len_two_rejected(self) -> None:
|
||||
# Name len = 2 (below _EXTRACT_MIN_NAME_LEN = 3).
|
||||
data = b"\x00" + struct.pack("<I", 2) + b"AB" + b"\x00" * 50
|
||||
assert _try_extract_player(data, 0) is None
|
||||
|
||||
def test_name_too_long(self) -> None:
|
||||
data = b"\x00" + struct.pack("<I", 100) + b"A" * 100 + b"\x00" * 50
|
||||
assert _try_extract_player(data, 0) is None
|
||||
|
||||
def test_invalid_name(self) -> None:
|
||||
# Name with only digits.
|
||||
data = b"\x00" + struct.pack("<I", 5) + b"12345" + b"\x00" * 50
|
||||
assert _try_extract_player(data, 0) is None
|
||||
|
||||
def test_data_too_short_after_name(self) -> None:
|
||||
name = b"John"
|
||||
# Need len >= prefix_offset+30=30 to pass first check,
|
||||
# but ne+25 > len to hit line 196. ne = 5+4 = 9, need < 34.
|
||||
# 1 prefix + 4 uint32 + 4 name + 21 padding = 30 bytes total.
|
||||
data = b"\x00" + struct.pack("<I", len(name)) + name + b"\x00" * 21
|
||||
assert len(data) == 30 # passes 30 check, but 9+25=34 > 30
|
||||
assert _try_extract_player(data, 0) is None
|
||||
|
||||
def test_invalid_personality(self) -> None:
|
||||
rec = _make_player_record(
|
||||
"Test Player",
|
||||
personality=b"\xff\xff\xff\xff\xff\xff\xff\xff",
|
||||
)
|
||||
padding = b"\x00" * 10
|
||||
result = _try_extract_player(rec + padding, 0)
|
||||
assert result is not None
|
||||
player, _ = result
|
||||
assert player.personality == []
|
||||
|
||||
def test_nonzero_prefix_offset(self) -> None:
|
||||
prefix = b"\xff" * 10
|
||||
rec = _make_player_record("Jane Doe")
|
||||
padding = b"\x00" * 10
|
||||
result = _try_extract_player(prefix + rec + padding, 10)
|
||||
assert result is not None
|
||||
assert result[0].uid == 10
|
||||
assert result[0].name == "Jane Doe"
|
||||
|
||||
|
||||
class TestPass1SeparatorWalk:
|
||||
"""_pass1_separator_walk tests."""
|
||||
|
||||
def test_finds_records(self) -> None:
|
||||
rec = _make_player_record("John Smith")
|
||||
# Build data: 12-byte header + separator + record + padding.
|
||||
data = b"\x00" * 12 + REC_SEP + rec + b"\x00" * 30
|
||||
players: list[Player] = []
|
||||
seen: set[int] = set()
|
||||
_pass1_separator_walk(data, players, seen)
|
||||
assert len(players) >= 1
|
||||
assert players[0].name == "John Smith"
|
||||
|
||||
def test_dedup_by_offset(self) -> None:
|
||||
rec = _make_player_record("John Smith")
|
||||
data = b"\x00" * 12 + REC_SEP + rec + b"\x00" * 30
|
||||
players: list[Player] = []
|
||||
expected_offset = 12 + len(REC_SEP)
|
||||
seen: set[int] = {expected_offset}
|
||||
_pass1_separator_walk(data, players, seen)
|
||||
assert len(players) == 0
|
||||
|
||||
def test_invalid_record_advances(self) -> None:
|
||||
# Separator followed by non-zero prefix byte.
|
||||
data = b"\x00" * 12 + REC_SEP + b"\x01" + b"\x00" * 50
|
||||
players: list[Player] = []
|
||||
seen: set[int] = set()
|
||||
_pass1_separator_walk(data, players, seen)
|
||||
assert len(players) == 0
|
||||
|
||||
def test_no_separators(self) -> None:
|
||||
data = b"\x00" * 100
|
||||
players: list[Player] = []
|
||||
seen: set[int] = set()
|
||||
_pass1_separator_walk(data, players, seen)
|
||||
assert len(players) == 0
|
||||
|
||||
|
||||
class TestPass2RegexScan:
|
||||
"""_pass2_regex_scan tests."""
|
||||
|
||||
def test_finds_records(self) -> None:
|
||||
rec = _make_player_record("Anna Baker")
|
||||
data = b"\x00" * 50 + rec + b"\x00" * 30
|
||||
players: list[Player] = []
|
||||
seen: set[int] = set()
|
||||
_pass2_regex_scan(data, players, seen)
|
||||
found = [p for p in players if p.name == "Anna Baker"]
|
||||
assert len(found) >= 1
|
||||
|
||||
def test_dedup_seen(self) -> None:
|
||||
rec = _make_player_record("Anna Baker")
|
||||
offset = 50
|
||||
data = b"\x00" * offset + rec + b"\x00" * 30
|
||||
players: list[Player] = []
|
||||
seen: set[int] = {offset}
|
||||
_pass2_regex_scan(data, players, seen)
|
||||
found = [p for p in players if p.name == "Anna Baker"]
|
||||
assert len(found) == 0
|
||||
|
||||
def test_progress_callback(self) -> None:
|
||||
rec = _make_player_record("Test Player")
|
||||
data = b"\x00" * 50 + rec + b"\x00" * 30
|
||||
cb = MagicMock()
|
||||
players: list[Player] = []
|
||||
seen: set[int] = set()
|
||||
_pass2_regex_scan(data, players, seen, progress_cb=cb)
|
||||
# Callback should be called at i=0 (0 % 50000 == 0).
|
||||
if players:
|
||||
cb.assert_called()
|
||||
|
||||
def test_no_dob_or_multiword_skipped(self) -> None:
|
||||
# Single-word name without DOB → should be skipped.
|
||||
name = b"Noname"
|
||||
rec = b"\x00" + struct.pack("<I", len(name)) + name
|
||||
# DOB with invalid year.
|
||||
rec += struct.pack("<HH", 180, 0) # year=0 → invalid DOB
|
||||
rec += b"\x07\x03\x0b\x06\x0c" # flags
|
||||
rec += b"\x8b\x00\x00\x00\x01\x03\x00\x00" # nat + ref
|
||||
rec += b"\x0a\x0b\x0c\x0d\x0e\x0f\x10\x05" # personality
|
||||
data = b"\x00" * 50 + rec + b"\x00" * 30
|
||||
players: list[Player] = []
|
||||
seen: set[int] = set()
|
||||
_pass2_regex_scan(data, players, seen)
|
||||
found = [p for p in players if p.name == "Noname"]
|
||||
assert len(found) == 0
|
||||
|
||||
def test_regex_match_invalid_player(self) -> None:
|
||||
"""Regex matches but _try_extract_player returns None (254→261)."""
|
||||
# Regex pattern: \x00, len_byte, \x00\x00\x00, [A-Z].
|
||||
# Name starts with 'A' (matches regex) but rest is non-printable.
|
||||
rec = b"\x00\x05\x00\x00\x00A\x01\x01\x01\x01" + b"\x00" * 30
|
||||
data = b"\xff" * 50 + rec + b"\x00" * 30
|
||||
players: list[Player] = []
|
||||
seen: set[int] = set()
|
||||
_pass2_regex_scan(data, players, seen)
|
||||
assert len(players) == 0
|
||||
|
||||
|
||||
class TestParsePeopleDb:
|
||||
"""parse_people_db integration tests with synthetic data."""
|
||||
|
||||
def _make_db(self, tmp_path: Path, player_names: list[str]) -> Path:
|
||||
"""Build a minimal TAD database file."""
|
||||
# 8 byte inner header + uint32 record_count.
|
||||
inner = b"\x00" * 8 + struct.pack("<I", len(player_names))
|
||||
for name in player_names:
|
||||
inner += REC_SEP
|
||||
inner += _make_player_record(name)
|
||||
inner += b"\x00" * 50 # padding
|
||||
filepath = tmp_path / "people_db.dat"
|
||||
filepath.write_bytes(_make_tad(inner))
|
||||
return filepath
|
||||
|
||||
def test_parse_with_progress(self, tmp_path: Path) -> None:
|
||||
filepath = self._make_db(tmp_path, ["Alpha Beta", "Gamma Delta"])
|
||||
cb = MagicMock()
|
||||
players = parse_people_db(filepath, progress_cb=cb)
|
||||
assert len(players) >= 2
|
||||
names = {p.name for p in players}
|
||||
assert "Alpha Beta" in names
|
||||
assert "Gamma Delta" in names
|
||||
cb.assert_called()
|
||||
|
||||
def test_parse_no_progress(self, tmp_path: Path) -> None:
|
||||
filepath = self._make_db(tmp_path, ["Just Name"])
|
||||
players = parse_people_db(filepath)
|
||||
assert any(p.name == "Just Name" for p in players)
|
||||
|
||||
def test_empty_db(self, tmp_path: Path) -> None:
|
||||
filepath = self._make_db(tmp_path, [])
|
||||
players = parse_people_db(filepath)
|
||||
assert isinstance(players, list)
|
||||
|
||||
|
||||
def _make_attr_block(
|
||||
*,
|
||||
vals: dict[int, int] | None = None,
|
||||
) -> list[int]:
|
||||
"""Build a valid 63-byte attribute block.
|
||||
|
||||
Zeros at positions 20-25 and 40-42, reasonable
|
||||
non-zero values at all confirmed attribute positions.
|
||||
Overrides via *vals*.
|
||||
"""
|
||||
block = [0] * 63
|
||||
# Fill confirmed positions with default non-zero values.
|
||||
defaults: dict[int, int] = {
|
||||
9: 15,
|
||||
10: 20,
|
||||
11: 17,
|
||||
12: 10,
|
||||
13: 16,
|
||||
14: 4,
|
||||
15: 14,
|
||||
16: 19,
|
||||
17: 17,
|
||||
18: 7,
|
||||
19: 20,
|
||||
26: 16,
|
||||
27: 18,
|
||||
29: 5,
|
||||
31: 19,
|
||||
32: 20,
|
||||
33: 20,
|
||||
34: 12,
|
||||
35: 20,
|
||||
36: 15,
|
||||
37: 14,
|
||||
38: 9,
|
||||
39: 4,
|
||||
43: 16,
|
||||
44: 18,
|
||||
45: 9,
|
||||
46: 13,
|
||||
47: 15,
|
||||
48: 6,
|
||||
49: 14,
|
||||
50: 9,
|
||||
51: 18,
|
||||
52: 10,
|
||||
53: 16,
|
||||
54: 7,
|
||||
55: 15,
|
||||
56: 18,
|
||||
57: 6,
|
||||
58: 12,
|
||||
59: 14,
|
||||
60: 20,
|
||||
61: 16,
|
||||
62: 13,
|
||||
}
|
||||
for pos, val in defaults.items():
|
||||
block[pos] = val
|
||||
if vals:
|
||||
for pos, val in vals.items():
|
||||
block[pos] = val
|
||||
return block
|
||||
|
||||
|
||||
class TestIsValidAttrBlock:
|
||||
"""_is_valid_attr_block tests."""
|
||||
|
||||
def test_valid_block(self) -> None:
|
||||
block = bytes(_make_attr_block())
|
||||
assert _is_valid_attr_block(block) is True
|
||||
|
||||
def test_value_above_20_rejected(self) -> None:
|
||||
raw = _make_attr_block()
|
||||
raw[9] = 21
|
||||
assert _is_valid_attr_block(bytes(raw)) is False
|
||||
|
||||
def test_nonzero_in_zero_range_rejected(self) -> None:
|
||||
raw = _make_attr_block()
|
||||
raw[22] = 1
|
||||
assert _is_valid_attr_block(bytes(raw)) is False
|
||||
|
||||
def test_nonzero_at_pos_40_rejected(self) -> None:
|
||||
raw = _make_attr_block()
|
||||
raw[40] = 1
|
||||
assert _is_valid_attr_block(bytes(raw)) is False
|
||||
|
||||
def test_too_few_nonzero_rejected(self) -> None:
|
||||
block = bytes([0] * 63)
|
||||
assert _is_valid_attr_block(block) is False
|
||||
|
||||
def test_exactly_min_nonzero(self) -> None:
|
||||
raw = [0] * 63
|
||||
# Fill exactly 30 positions outside zero ranges.
|
||||
keep = [
|
||||
i for i in range(63) if i not in range(20, 26) and i not in {40, 41, 42}
|
||||
][:30]
|
||||
for pos in keep:
|
||||
raw[pos] = 10
|
||||
assert _is_valid_attr_block(bytes(raw)) is True
|
||||
|
||||
|
||||
class TestFindAllAttrBlocks:
|
||||
"""_find_all_attr_blocks tests."""
|
||||
|
||||
def test_single_block_found(self) -> None:
|
||||
block = bytes(_make_attr_block())
|
||||
data = b"\xff" * 100 + block + b"\xff" * 100
|
||||
offsets, values = _find_all_attr_blocks(data)
|
||||
assert len(offsets) == 1
|
||||
assert offsets[0] == 100
|
||||
assert values[0] == list(block)
|
||||
|
||||
def test_multiple_blocks(self) -> None:
|
||||
blk1 = bytes(_make_attr_block(vals={9: 15}))
|
||||
blk2 = bytes(_make_attr_block(vals={9: 18}))
|
||||
data = b"\xff" * 50 + blk1 + b"\xff" * 200 + blk2 + b"\xff" * 50
|
||||
offsets, _values = _find_all_attr_blocks(data)
|
||||
assert len(offsets) == 2
|
||||
assert offsets[0] < offsets[1]
|
||||
|
||||
def test_no_blocks_in_random_data(self) -> None:
|
||||
data = bytes(range(256)) * 10
|
||||
offsets, _values = _find_all_attr_blocks(data)
|
||||
assert offsets == []
|
||||
|
||||
def test_empty_data(self) -> None:
|
||||
offsets, values = _find_all_attr_blocks(b"")
|
||||
assert offsets == []
|
||||
assert values == []
|
||||
|
||||
def test_block_at_start(self) -> None:
|
||||
block = bytes(_make_attr_block())
|
||||
data = block + b"\xff" * 100
|
||||
offsets, _ = _find_all_attr_blocks(data)
|
||||
assert len(offsets) == 1
|
||||
assert offsets[0] == 0
|
||||
|
||||
def test_data_too_short_for_block(self) -> None:
|
||||
data = b"\x00" * 30
|
||||
offsets, _ = _find_all_attr_blocks(data)
|
||||
assert offsets == []
|
||||
|
||||
|
||||
class TestAttrsFromBlock:
|
||||
"""_attrs_from_block tests."""
|
||||
|
||||
def test_extracts_confirmed_attrs(self) -> None:
|
||||
block = _make_attr_block()
|
||||
attrs = _attrs_from_block(block)
|
||||
assert attrs["Crossing"] == 15
|
||||
assert attrs["Technique"] == 20
|
||||
assert attrs["Determination"] == 20
|
||||
assert attrs["Concentration"] == 13
|
||||
assert attrs["Strength"] == 9
|
||||
assert attrs["Jumping Reach"] == 6
|
||||
assert attrs["Agility"] == 18
|
||||
assert attrs["Stamina"] == 12
|
||||
assert attrs["Pace"] == 16
|
||||
assert len(attrs) == len(ATTR_BLOCK_MAP)
|
||||
|
||||
def test_zero_values_excluded(self) -> None:
|
||||
block = _make_attr_block(vals={9: 0})
|
||||
attrs = _attrs_from_block(block)
|
||||
assert "Crossing" not in attrs
|
||||
|
||||
def test_all_mapped_positions_zero(self) -> None:
|
||||
block = [0] * 63
|
||||
attrs = _attrs_from_block(block)
|
||||
assert attrs == {}
|
||||
|
||||
|
||||
class TestEnrichWithAttributes:
|
||||
"""_enrich_with_attributes tests."""
|
||||
|
||||
def test_block_assigned_to_nearby_player(self) -> None:
|
||||
block = bytes(_make_attr_block())
|
||||
data = b"\xff" * 100 + block + b"\xff" * 100
|
||||
player = Player(uid=200, name="Test")
|
||||
_enrich_with_attributes(data, [player])
|
||||
assert player.attributes.get("Crossing") == 15
|
||||
|
||||
def test_block_too_far_away_ignored(self) -> None:
|
||||
block = bytes(_make_attr_block())
|
||||
data = b"\xff" * 10 + block + b"\xff" * 2000
|
||||
player = Player(uid=2000, name="Test")
|
||||
_enrich_with_attributes(data, [player])
|
||||
assert player.attributes == {}
|
||||
|
||||
def test_no_blocks_found(self) -> None:
|
||||
data = bytes(range(256)) * 10
|
||||
player = Player(uid=100, name="Test")
|
||||
_enrich_with_attributes(data, [player])
|
||||
assert player.attributes == {}
|
||||
|
||||
def test_player_before_all_blocks(self) -> None:
|
||||
block = bytes(_make_attr_block())
|
||||
data = b"\xff" * 500 + block + b"\xff" * 100
|
||||
player = Player(uid=10, name="Test")
|
||||
_enrich_with_attributes(data, [player])
|
||||
assert player.attributes == {}
|
||||
|
||||
def test_progress_callback(self) -> None:
|
||||
block = bytes(_make_attr_block())
|
||||
data = b"\xff" * 100 + block + b"\xff" * 100
|
||||
player = Player(uid=200, name="Test")
|
||||
cb = MagicMock()
|
||||
_enrich_with_attributes(data, [player], progress_cb=cb)
|
||||
assert cb.call_count >= 2
|
||||
|
||||
def test_multiple_players_multiple_blocks(self) -> None:
|
||||
blk1 = bytes(_make_attr_block(vals={9: 12}))
|
||||
blk2 = bytes(_make_attr_block(vals={9: 18}))
|
||||
gap = b"\xff" * 200
|
||||
data = gap + blk1 + gap + gap + blk2 + gap
|
||||
off1 = 200
|
||||
off2 = 200 + 63 + 200 + 200
|
||||
p1 = Player(uid=off1 + 63 + 50, name="Player1")
|
||||
p2 = Player(uid=off2 + 63 + 50, name="Player2")
|
||||
_enrich_with_attributes(data, [p1, p2])
|
||||
assert p1.attributes.get("Crossing") == 12
|
||||
assert p2.attributes.get("Crossing") == 18
|
||||
|
||||
|
||||
class TestSearchPlayers:
|
||||
"""search_players tests."""
|
||||
|
||||
def test_match(self) -> None:
|
||||
players = [Player(name="John Smith"), Player(name="Jane Doe")]
|
||||
result = search_players(players, "john")
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "John Smith"
|
||||
|
||||
def test_no_match(self) -> None:
|
||||
players = [Player(name="John Smith")]
|
||||
assert search_players(players, "xyz") == []
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
players = [Player(name="John Smith")]
|
||||
assert len(search_players(players, "JOHN")) == 1
|
||||
|
||||
def test_partial_match(self) -> None:
|
||||
players = [Player(name="Jonathan")]
|
||||
assert len(search_players(players, "jona")) == 1
|
||||
373
python_pkg/fm24_searcher/tests/test_cli.py
Normal file
373
python_pkg/fm24_searcher/tests/test_cli.py
Normal file
@ -0,0 +1,373 @@
|
||||
"""Tests for python_pkg.fm24_searcher.cli."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import zstandard
|
||||
|
||||
from python_pkg.fm24_searcher.binary_parser import TAD_MAGIC
|
||||
from python_pkg.fm24_searcher.cli import (
|
||||
_DEFAULT_LIMIT,
|
||||
_format_player,
|
||||
_format_tsv_header,
|
||||
_format_tsv_row,
|
||||
_print_stats,
|
||||
build_parser,
|
||||
run_dump,
|
||||
)
|
||||
from python_pkg.fm24_searcher.models import ALL_VISIBLE_ATTRS, Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _zstd_compress(data: bytes) -> bytes:
|
||||
"""Compress data with zstd."""
|
||||
cctx = zstandard.ZstdCompressor()
|
||||
result: bytes = cctx.compress(data)
|
||||
return result
|
||||
|
||||
|
||||
def _make_tad(payload: bytes) -> bytes:
|
||||
"""Create a TAD-formatted blob."""
|
||||
return TAD_MAGIC + _zstd_compress(payload)
|
||||
|
||||
|
||||
def _make_player_record(
|
||||
name: str,
|
||||
*,
|
||||
day_of_year: int = 180,
|
||||
year: int = 1995,
|
||||
) -> bytes:
|
||||
"""Build a minimal binary player record."""
|
||||
name_bytes = name.encode("utf-8")
|
||||
rec = b"\x00"
|
||||
rec += struct.pack("<I", len(name_bytes))
|
||||
rec += name_bytes
|
||||
rec += struct.pack("<H", day_of_year)
|
||||
rec += struct.pack("<H", year)
|
||||
rec += b"\x07\x03\x0b\x06\x0c"
|
||||
rec += b"\x8b\x00\x00\x00\x01\x03\x00\x00"
|
||||
rec += b"\x0a\x0b\x0c\x0d\x0e\x0f\x10\x05"
|
||||
return rec
|
||||
|
||||
|
||||
def _make_db(tmp_path: Path, names: list[str]) -> Path:
|
||||
"""Build a minimal TAD database file."""
|
||||
sep = b"\x05\x00\x00\x00\x00"
|
||||
inner = b"\x00" * 8 + struct.pack("<I", len(names))
|
||||
for name in names:
|
||||
inner += sep + _make_player_record(name)
|
||||
inner += b"\x00" * 50
|
||||
filepath = tmp_path / "people_db.dat"
|
||||
filepath.write_bytes(_make_tad(inner))
|
||||
return filepath
|
||||
|
||||
|
||||
class TestFormatPlayer:
|
||||
"""_format_player tests."""
|
||||
|
||||
def test_basic_fields(self) -> None:
|
||||
p = Player(
|
||||
name="Test Player",
|
||||
date_of_birth="1995-06-29",
|
||||
source="binary",
|
||||
uid=100,
|
||||
)
|
||||
result = _format_player(p)
|
||||
assert "=== Test Player ===" in result
|
||||
assert "DOB: 1995-06-29" in result
|
||||
assert "Source: binary" in result
|
||||
assert "UID (byte offset): 100" in result
|
||||
|
||||
def test_with_attrs(self) -> None:
|
||||
p = Player(
|
||||
name="Star",
|
||||
attributes={"Crossing": 15, "Finishing": 18},
|
||||
source="binary",
|
||||
)
|
||||
result = _format_player(p, show_attrs=True)
|
||||
assert "Crossing: 15" in result
|
||||
assert "Finishing: 18" in result
|
||||
assert "Missing attrs:" in result
|
||||
|
||||
def test_no_attrs_block(self) -> None:
|
||||
p = Player(name="Noattr", source="binary")
|
||||
result = _format_player(p, show_attrs=True)
|
||||
assert "(no attribute block found)" in result
|
||||
|
||||
def test_all_optional_fields(self) -> None:
|
||||
p = Player(
|
||||
name="Full",
|
||||
current_ability=160,
|
||||
potential_ability=190,
|
||||
nationality="Argentina",
|
||||
club="Inter Miami",
|
||||
position="AM (R,C), ST",
|
||||
personality=[10, 11, 12, 13, 14, 15, 16, 5],
|
||||
source="binary",
|
||||
)
|
||||
result = _format_player(p)
|
||||
assert "CA: 160" in result
|
||||
assert "PA: 190" in result
|
||||
assert "Nationality: Argentina" in result
|
||||
assert "Club: Inter Miami" in result
|
||||
assert "Position: AM (R,C), ST" in result
|
||||
assert "Personality bytes:" in result
|
||||
|
||||
def test_no_optional_fields(self) -> None:
|
||||
p = Player(name="Minimal", source="binary", uid=0)
|
||||
result = _format_player(p)
|
||||
assert "DOB:" not in result
|
||||
assert "CA:" not in result
|
||||
assert "PA:" not in result
|
||||
|
||||
def test_attrs_all_present(self) -> None:
|
||||
attrs = dict.fromkeys(ALL_VISIBLE_ATTRS, 10)
|
||||
p = Player(name="Complete", attributes=attrs, source="binary")
|
||||
result = _format_player(p, show_attrs=True)
|
||||
assert "Missing attrs:" not in result
|
||||
|
||||
def test_attrs_show_false(self) -> None:
|
||||
p = Player(
|
||||
name="Skip",
|
||||
attributes={"Crossing": 15},
|
||||
source="binary",
|
||||
)
|
||||
result = _format_player(p, show_attrs=False)
|
||||
assert "Crossing" not in result
|
||||
|
||||
|
||||
class TestFormatTsv:
|
||||
"""TSV formatting tests."""
|
||||
|
||||
def test_header_without_attrs(self) -> None:
|
||||
hdr = _format_tsv_header(show_attrs=False)
|
||||
assert hdr == "Name\tDOB\tCA\tPA\tPersonality\tUID"
|
||||
|
||||
def test_header_with_attrs(self) -> None:
|
||||
hdr = _format_tsv_header(show_attrs=True)
|
||||
assert "Corners" in hdr
|
||||
assert "Acceleration" in hdr
|
||||
|
||||
def test_row_basic(self) -> None:
|
||||
p = Player(
|
||||
name="John",
|
||||
date_of_birth="1995-01-01",
|
||||
personality=[1, 2, 3],
|
||||
uid=50,
|
||||
source="binary",
|
||||
)
|
||||
row = _format_tsv_row(p, show_attrs=False)
|
||||
parts = row.split("\t")
|
||||
assert parts[0] == "John"
|
||||
assert parts[1] == "1995-01-01"
|
||||
assert parts[4] == "1,2,3"
|
||||
assert parts[5] == "50"
|
||||
|
||||
def test_row_with_attrs(self) -> None:
|
||||
p = Player(
|
||||
name="Star",
|
||||
attributes={"Crossing": 15},
|
||||
uid=10,
|
||||
source="binary",
|
||||
)
|
||||
row = _format_tsv_row(p, show_attrs=True)
|
||||
assert "15" in row
|
||||
|
||||
def test_row_empty_fields(self) -> None:
|
||||
p = Player(name="Empty", uid=0, source="binary")
|
||||
row = _format_tsv_row(p, show_attrs=False)
|
||||
parts = row.split("\t")
|
||||
assert parts[2] == "" # CA
|
||||
assert parts[3] == "" # PA
|
||||
|
||||
|
||||
class TestBuildParser:
|
||||
"""build_parser tests."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["--dump"])
|
||||
assert args.dump is True
|
||||
assert args.search == ""
|
||||
assert args.limit == _DEFAULT_LIMIT
|
||||
assert args.attrs is False
|
||||
assert args.tsv is False
|
||||
|
||||
def test_all_flags(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
"--dump",
|
||||
"--search",
|
||||
"Messi",
|
||||
"--limit",
|
||||
"10",
|
||||
"--attrs",
|
||||
"--tsv",
|
||||
"--with-attrs-only",
|
||||
"--stats",
|
||||
]
|
||||
)
|
||||
assert args.search == "Messi"
|
||||
assert args.limit == 10
|
||||
assert args.attrs is True
|
||||
assert args.tsv is True
|
||||
assert args.with_attrs_only is True
|
||||
assert args.stats is True
|
||||
|
||||
def test_custom_db(self, tmp_path: Path) -> None:
|
||||
parser = build_parser()
|
||||
db_path = str(tmp_path)
|
||||
args = parser.parse_args(["--dump", "--db", db_path])
|
||||
assert args.db == db_path
|
||||
|
||||
|
||||
class TestPrintStats:
|
||||
"""_print_stats tests."""
|
||||
|
||||
def test_basic_stats(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
players = [
|
||||
Player(
|
||||
name="A",
|
||||
date_of_birth="1995-01-01",
|
||||
attributes={"Crossing": 15, "Finishing": 18},
|
||||
current_ability=160,
|
||||
source="binary",
|
||||
),
|
||||
Player(name="B", source="binary"),
|
||||
]
|
||||
_print_stats(players)
|
||||
out = capsys.readouterr().out
|
||||
assert "Total players: 2" in out
|
||||
assert "With DOB: 1" in out
|
||||
assert "With attributes: 1" in out
|
||||
assert "With CA/PA: 1" in out
|
||||
assert "Crossing" in out
|
||||
|
||||
def test_empty_players(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
_print_stats([])
|
||||
out = capsys.readouterr().out
|
||||
assert "Total players: 0" in out
|
||||
|
||||
def test_no_attrs(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
players = [Player(name="X", source="binary")]
|
||||
_print_stats(players)
|
||||
out = capsys.readouterr().out
|
||||
assert "With attributes: 0" in out
|
||||
# No attr coverage section when no attrs
|
||||
assert "Attribute coverage" not in out
|
||||
|
||||
|
||||
class TestRunDump:
|
||||
"""run_dump integration tests."""
|
||||
|
||||
def test_no_dump_flag(self) -> None:
|
||||
assert run_dump([]) == 1
|
||||
|
||||
def test_db_not_found(self) -> None:
|
||||
result = run_dump(["--dump", "--db", "/nonexistent/db.dat"])
|
||||
assert result == 2
|
||||
|
||||
def test_dump_text(self, tmp_path: Path) -> None:
|
||||
db = _make_db(tmp_path, ["Alpha Beta", "Gamma Delta"])
|
||||
result = run_dump(
|
||||
[
|
||||
"--dump",
|
||||
"--db",
|
||||
str(db),
|
||||
"--limit",
|
||||
"5",
|
||||
]
|
||||
)
|
||||
assert result == 0
|
||||
|
||||
def test_dump_text_output(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["Alpha Beta"])
|
||||
run_dump(["--dump", "--db", str(db), "--limit", "5"])
|
||||
out = capsys.readouterr().out
|
||||
assert "Alpha Beta" in out
|
||||
assert "Showing" in out
|
||||
|
||||
def test_dump_tsv(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["TSV Player"])
|
||||
run_dump(["--dump", "--db", str(db), "--tsv"])
|
||||
out = capsys.readouterr().out
|
||||
assert "Name\tDOB\tCA\tPA" in out
|
||||
assert "TSV Player" in out
|
||||
|
||||
def test_dump_with_search(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["Alpha Beta", "Gamma Delta"])
|
||||
run_dump(["--dump", "--db", str(db), "--search", "alpha"])
|
||||
out = capsys.readouterr().out
|
||||
assert "Alpha Beta" in out
|
||||
assert "Gamma Delta" not in out
|
||||
|
||||
def test_dump_with_attrs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["Attr Check"])
|
||||
run_dump(["--dump", "--db", str(db), "--attrs"])
|
||||
out = capsys.readouterr().out
|
||||
assert "Attr Check" in out
|
||||
|
||||
def test_dump_tsv_with_attrs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["TSV Attrs"])
|
||||
run_dump(["--dump", "--db", str(db), "--tsv", "--attrs"])
|
||||
out = capsys.readouterr().out
|
||||
assert "Corners" in out
|
||||
|
||||
def test_dump_with_attrs_only(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["No Attrs"])
|
||||
run_dump(["--dump", "--db", str(db), "--with-attrs-only"])
|
||||
out = capsys.readouterr().out
|
||||
# No players should have attrs in synthetic data.
|
||||
assert "Showing 0 of 0" in out
|
||||
|
||||
def test_dump_stats(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["Stats Test"])
|
||||
result = run_dump(["--dump", "--db", str(db), "--stats"])
|
||||
assert result == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Total players:" in out
|
||||
|
||||
def test_progress_goes_to_stderr(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
db = _make_db(tmp_path, ["Progress"])
|
||||
run_dump(["--dump", "--db", str(db)])
|
||||
err = capsys.readouterr().err
|
||||
assert "Decompressing" in err
|
||||
1177
python_pkg/fm24_searcher/tests/test_gui.py
Normal file
1177
python_pkg/fm24_searcher/tests/test_gui.py
Normal file
File diff suppressed because it is too large
Load Diff
402
python_pkg/fm24_searcher/tests/test_html_parser.py
Normal file
402
python_pkg/fm24_searcher/tests/test_html_parser.py
Normal file
@ -0,0 +1,402 @@
|
||||
"""Tests for python_pkg.fm24_searcher.html_parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.fm24_searcher.html_parser import (
|
||||
_extract_tables,
|
||||
_normalize_header,
|
||||
_strip_html,
|
||||
merge_players,
|
||||
parse_html_export,
|
||||
)
|
||||
from python_pkg.fm24_searcher.models import Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestStripHtml:
|
||||
"""_strip_html tests."""
|
||||
|
||||
def test_removes_tags(self) -> None:
|
||||
assert _strip_html("<b>bold</b>") == "bold"
|
||||
|
||||
def test_decodes_entities(self) -> None:
|
||||
assert _strip_html("& <") == "& <"
|
||||
|
||||
def test_collapses_whitespace(self) -> None:
|
||||
assert _strip_html(" hello world ") == "hello world"
|
||||
|
||||
def test_nested_tags(self) -> None:
|
||||
assert _strip_html("<div><span>text</span></div>") == "text"
|
||||
|
||||
|
||||
class TestNormalizeHeader:
|
||||
"""_normalize_header tests."""
|
||||
|
||||
def test_direct_map(self) -> None:
|
||||
assert _normalize_header("cor") == "Corners"
|
||||
assert _normalize_header("fin") == "Finishing"
|
||||
|
||||
def test_full_name_lower(self) -> None:
|
||||
assert _normalize_header("Acceleration") == "Acceleration"
|
||||
|
||||
def test_truncated_3char(self) -> None:
|
||||
assert _normalize_header("crossing") == "Crossing"
|
||||
|
||||
def test_unknown(self) -> None:
|
||||
assert _normalize_header("xyz") is None
|
||||
|
||||
def test_truncated_prefix_only(self) -> None:
|
||||
"""Full string not in map but first 3 chars match (line 113)."""
|
||||
assert _normalize_header("corxxx") == "Corners"
|
||||
|
||||
def test_html_in_header(self) -> None:
|
||||
assert _normalize_header("<b>cor</b>") == "Corners"
|
||||
|
||||
def test_goalkeeper_attr(self) -> None:
|
||||
assert _normalize_header("han") == "Handling"
|
||||
assert _normalize_header("ref") == "Reflexes"
|
||||
|
||||
|
||||
class TestExtractTables:
|
||||
"""_extract_tables tests."""
|
||||
|
||||
def test_single_table(self) -> None:
|
||||
html = (
|
||||
"<table><tr><th>Name</th><th>Age</th></tr>"
|
||||
"<tr><td>John</td><td>25</td></tr></table>"
|
||||
)
|
||||
tables = _extract_tables(html)
|
||||
assert len(tables) == 1
|
||||
assert tables[0][0] == ["Name", "Age"]
|
||||
assert tables[0][1] == ["John", "25"]
|
||||
|
||||
def test_multiple_tables(self) -> None:
|
||||
html = "<table><tr><td>A</td></tr></table><table><tr><td>B</td></tr></table>"
|
||||
tables = _extract_tables(html)
|
||||
assert len(tables) == 2
|
||||
|
||||
def test_empty_table_filtered(self) -> None:
|
||||
html = "<table></table><table><tr><td>X</td></tr></table>"
|
||||
tables = _extract_tables(html)
|
||||
assert len(tables) == 1
|
||||
|
||||
def test_nested_html_stripped(self) -> None:
|
||||
html = "<table><tr><td><b>Bold</b></td></tr></table>"
|
||||
tables = _extract_tables(html)
|
||||
assert tables[0][0] == ["Bold"]
|
||||
|
||||
def test_row_without_cells(self) -> None:
|
||||
"""Row with no cells is skipped (branch 140→135)."""
|
||||
html = "<table><tr></tr><tr><td>valid</td></tr></table>"
|
||||
tables = _extract_tables(html)
|
||||
assert len(tables) == 1
|
||||
assert len(tables[0]) == 1
|
||||
assert tables[0][0] == ["valid"]
|
||||
|
||||
|
||||
class TestParseHtmlExport:
|
||||
"""parse_html_export tests."""
|
||||
|
||||
def _write_html(self, tmp_path: Path, content: str) -> Path:
|
||||
p = tmp_path / "export.html"
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
def test_basic_table(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Age</th><th>CA</th>"
|
||||
"<th>PA</th><th>Pac</th><th>Sta</th></tr>"
|
||||
"<tr><td>John Smith</td><td>25</td><td>170</td>"
|
||||
"<td>180</td><td>18</td><td>15</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert len(players) == 1
|
||||
assert players[0].name == "John Smith"
|
||||
assert players[0].current_ability == 170
|
||||
assert players[0].potential_ability == 180
|
||||
assert players[0].attributes["Pace"] == 18
|
||||
assert players[0].attributes["Stamina"] == 15
|
||||
assert players[0].source == "html"
|
||||
|
||||
def test_no_name_column(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>CA</th><th>PA</th></tr>"
|
||||
"<tr><td>170</td><td>180</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
assert parse_html_export(p) == []
|
||||
|
||||
def test_table_too_few_rows(self, tmp_path: Path) -> None:
|
||||
html = "<table><tr><th>Name</th></tr></table>"
|
||||
p = self._write_html(tmp_path, html)
|
||||
assert parse_html_export(p) == []
|
||||
|
||||
def test_row_shorter_than_name_col(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table><tr><th>A</th><th>Name</th></tr><tr><td>only_one</td></tr></table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
assert parse_html_export(p) == []
|
||||
|
||||
def test_empty_name_skipped(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th></tr>"
|
||||
"<tr><td></td></tr>"
|
||||
"<tr><td>Valid</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert len(players) == 1
|
||||
assert players[0].name == "Valid"
|
||||
|
||||
def test_invalid_ca_pa(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>CA</th><th>PA</th></tr>"
|
||||
"<tr><td>Test</td><td>abc</td><td>xyz</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert players[0].current_ability == 0
|
||||
|
||||
def test_attr_col_beyond_row_length(self, tmp_path: Path) -> None:
|
||||
"""Attribute col index >= row length (branch 240→239)."""
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Cor</th><th>Pac</th></tr>"
|
||||
"<tr><td>Player</td><td>15</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert len(players) == 1
|
||||
assert players[0].attributes.get("Corners") == 15
|
||||
assert "Pace" not in players[0].attributes
|
||||
assert players[0].potential_ability == 0
|
||||
|
||||
def test_club_nat_pos_value_wage(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Club</th><th>Nat</th>"
|
||||
"<th>Position</th><th>Value</th><th>Wage</th></tr>"
|
||||
"<tr><td>John</td><td>Madrid</td><td>Spain</td>"
|
||||
"<td>AMC</td><td>€50M</td><td>€200K</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert players[0].club == "Madrid"
|
||||
assert players[0].nationality == "Spain"
|
||||
assert players[0].position == "AMC"
|
||||
assert players[0].value == "€50M"
|
||||
assert players[0].wage == "€200K"
|
||||
|
||||
def test_range_format(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Pac</th></tr>"
|
||||
"<tr><td>Test</td><td>12-16</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert players[0].attributes["Pace"] == 12
|
||||
|
||||
def test_attr_out_of_range(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Pac</th></tr>"
|
||||
"<tr><td>Test</td><td>25</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert "Pace" not in players[0].attributes
|
||||
|
||||
def test_attr_not_int(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Pac</th></tr>"
|
||||
"<tr><td>Test</td><td>N/A</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert "Pace" not in players[0].attributes
|
||||
|
||||
def test_gk_attributes(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Han</th><th>Ref</th></tr>"
|
||||
"<tr><td>GK Test</td><td>15</td><td>18</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert players[0].gk_attributes["Handling"] == 15
|
||||
assert players[0].gk_attributes["Reflexes"] == 18
|
||||
|
||||
def test_player_header_name(self, tmp_path: Path) -> None:
|
||||
html = "<table><tr><th>Player</th></tr><tr><td>Alt Name</td></tr></table>"
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert players[0].name == "Alt Name"
|
||||
|
||||
def test_club_header_team(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Team</th></tr>"
|
||||
"<tr><td>Test</td><td>MyClub</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
assert parse_html_export(p)[0].club == "MyClub"
|
||||
|
||||
def test_ability_header(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Ability</th>"
|
||||
"<th>Potential</th></tr>"
|
||||
"<tr><td>T</td><td>150</td><td>180</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
players = parse_html_export(p)
|
||||
assert players[0].current_ability == 150
|
||||
assert players[0].potential_ability == 180
|
||||
|
||||
def test_nationality_header(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Nationality</th></tr>"
|
||||
"<tr><td>P</td><td>Brazil</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
assert parse_html_export(p)[0].nationality == "Brazil"
|
||||
|
||||
def test_pos_header(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Pos</th></tr>"
|
||||
"<tr><td>P</td><td>GK</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
assert parse_html_export(p)[0].position == "GK"
|
||||
|
||||
def test_val_header(self, tmp_path: Path) -> None:
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><th>Name</th><th>Val</th></tr>"
|
||||
"<tr><td>P</td><td>€10M</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
p = self._write_html(tmp_path, html)
|
||||
assert parse_html_export(p)[0].value == "€10M"
|
||||
|
||||
|
||||
class TestMergePlayers:
|
||||
"""merge_players tests."""
|
||||
|
||||
def test_match_by_name(self) -> None:
|
||||
bp = Player(
|
||||
name="John Smith",
|
||||
date_of_birth="1995-06-29",
|
||||
source="binary",
|
||||
)
|
||||
hp = Player(
|
||||
name="John Smith",
|
||||
current_ability=170,
|
||||
potential_ability=185,
|
||||
club="Madrid",
|
||||
nationality="Spain",
|
||||
position="AMC",
|
||||
value="€50M",
|
||||
wage="€200K",
|
||||
attributes={"Pace": 18},
|
||||
gk_attributes={"Handling": 15},
|
||||
source="html",
|
||||
)
|
||||
result = merge_players([bp], [hp])
|
||||
assert len(result) == 1
|
||||
merged = result[0]
|
||||
assert merged.source == "merged"
|
||||
assert merged.date_of_birth == "1995-06-29"
|
||||
assert merged.current_ability == 170
|
||||
assert merged.potential_ability == 185
|
||||
assert merged.club == "Madrid"
|
||||
assert merged.nationality == "Spain"
|
||||
assert merged.position == "AMC"
|
||||
assert merged.value == "€50M"
|
||||
assert merged.wage == "€200K"
|
||||
assert merged.attributes["Pace"] == 18
|
||||
assert merged.gk_attributes["Handling"] == 15
|
||||
|
||||
def test_html_only(self) -> None:
|
||||
hp = Player(name="HTML Only", source="html")
|
||||
result = merge_players([], [hp])
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "HTML Only"
|
||||
|
||||
def test_binary_only(self) -> None:
|
||||
bp = Player(name="Binary Only", source="binary")
|
||||
result = merge_players([bp], [])
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Binary Only"
|
||||
|
||||
def test_no_overwrite_when_html_zero(self) -> None:
|
||||
bp = Player(
|
||||
name="Test",
|
||||
current_ability=150,
|
||||
potential_ability=180,
|
||||
club="OldClub",
|
||||
source="binary",
|
||||
)
|
||||
hp = Player(
|
||||
name="Test",
|
||||
current_ability=0,
|
||||
potential_ability=0,
|
||||
club="",
|
||||
source="html",
|
||||
)
|
||||
result = merge_players([bp], [hp])
|
||||
assert result[0].current_ability == 150
|
||||
assert result[0].potential_ability == 180
|
||||
assert result[0].club == "OldClub"
|
||||
|
||||
def test_case_insensitive_match(self) -> None:
|
||||
bp = Player(name="JOHN SMITH", source="binary")
|
||||
hp = Player(
|
||||
name="john smith",
|
||||
attributes={"Pace": 15},
|
||||
source="html",
|
||||
)
|
||||
result = merge_players([bp], [hp])
|
||||
assert len(result) == 1
|
||||
assert result[0].attributes["Pace"] == 15
|
||||
|
||||
def test_mixed(self) -> None:
|
||||
bp1 = Player(name="Matched", source="binary")
|
||||
bp2 = Player(name="Binary Only", source="binary")
|
||||
hp1 = Player(
|
||||
name="Matched",
|
||||
club="Club",
|
||||
source="html",
|
||||
)
|
||||
hp2 = Player(name="HTML Only", source="html")
|
||||
result = merge_players([bp1, bp2], [hp1, hp2])
|
||||
names = {p.name for p in result}
|
||||
assert names == {"Matched", "Binary Only", "HTML Only"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user