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:
Krzysztof kuhy Rudnicki 2026-04-12 20:45:24 +02:00
parent ee41f014b2
commit 01091c09ce
102 changed files with 33652 additions and 1251 deletions

View File

@ -1,19 +1,47 @@
CC := gcc CC := gcc
CFLAGS := -O2 -Wall -Wextra -std=c11 -D_DEFAULT_SOURCE CFLAGS := -O2 -Wall -Wextra -std=c11 -D_DEFAULT_SOURCE
LDFLAGS := LDFLAGS := -lm
SRC := main.c SRC := main.c physics.c
BIN := 1dvelocitysimulator 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) all: $(BIN)
$(BIN): $(SRC) $(BIN): $(SRC) physics.h
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(CC) $(CFLAGS) -o $@ $(SRC) $(LDFLAGS)
run: $(BIN) run: $(BIN)
./$(BIN) ./$(BIN)
clean: test: $(TEST_BIN)
rm -f $(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

View File

@ -1,180 +1,4 @@
#include <math.h> #include "physics.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;
}
int main() int main()
{ {

View 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;
}

View 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 */

View 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;
}

View File

@ -1,5 +1,6 @@
CC := gcc CC := gcc
CFLAGS := -O2 -std=c11 -Wall -Wextra -Wno-unused-parameter CFLAGS := -O2 -std=c11 -Wall -Wextra -Wno-unused-parameter
COV := -O0 -g --coverage -std=c11 -Wall -Wextra -Wno-unused-parameter -Wno-return-type
LDFLAGS := LDFLAGS :=
SRC := main.c movegen.c search.c SRC := main.c movegen.c search.c
@ -9,7 +10,7 @@ BIN := random_engine
PERFT_SRC := perft.c movegen.c PERFT_SRC := perft.c movegen.c
PERFT_BIN := perft PERFT_BIN := perft
.PHONY: all clean rebuild .PHONY: all clean rebuild test coverage
all: $(BIN) all: $(BIN)
@ -19,8 +20,44 @@ $(BIN): $(SRC)
$(PERFT_BIN): $(PERFT_SRC) $(PERFT_BIN): $(PERFT_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(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: clean:
rm -f $(BIN) $(PERFT_BIN) rm -f $(BIN) $(PERFT_BIN) test_movegen test_search \
*.gcda *.gcno *.gcov coverage.info
rebuild: clean all rebuild: clean all

View File

@ -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) void set_startpos(Position *pos)
{ {
memset(pos, 0, sizeof(*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; break;
default:
break;
} }
} }

Binary file not shown.

View File

@ -5,13 +5,11 @@
static int piece_value(Piece p) static int piece_value(Piece p)
{ {
if (p == WK || p == BK)
{
return 0; // king is invaluable; PST handled later if needed
}
switch (p) switch (p)
{ {
case WK:
case BK:
return 0; /* king is invaluable; PST handled later if needed */
case WP: case WP:
case BP: case BP:
return 100; return 100;
@ -43,10 +41,6 @@ int evaluate(const Position *pos)
continue; continue;
} }
Piece p = pos->board[sq]; Piece p = pos->board[sq];
if (p == EMPTY)
{
continue;
}
int v = piece_value(p); int v = piece_value(p);
if (p >= WP && p <= WK) if (p >= WP && p <= WK)
{ {

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@ -1 +1,7 @@
split split
test_split
*.gcda
*.gcno
*.gcov
coverage.info
coverage_html/

View File

@ -2,9 +2,15 @@ CC := gcc
CFLAGS := -O2 -Wall -Wextra -std=c11 CFLAGS := -O2 -Wall -Wextra -std=c11
LDFLAGS := LDFLAGS :=
SRC := main.c SRC := main.c split.c
BIN := split 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) all: $(BIN)
$(BIN): $(SRC) $(BIN): $(SRC)
@ -13,7 +19,29 @@ $(BIN): $(SRC)
run: $(BIN) run: $(BIN)
./$(BIN) ./$(BIN)
clean: test: $(TEST_BIN)
rm -f $(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

View File

@ -1,86 +1,6 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
// Function to calculate symmetric weights for both even and odd N #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; // 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);
}
// Example usage // Example usage
int main(void) int main(void)

80
C/misc/split/split.c Normal file
View 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
View 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
View 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;
}

View File

@ -1,13 +1,43 @@
CC = gcc CC = gcc
CFLAGS = -O3 -Wall -Wextra -march=native CFLAGS = -O3 -Wall -Wextra -march=native
COV = -O0 -g --coverage -Wall -Wextra
TARGET = vocabulary_curve TARGET = vocabulary_curve
all: $(TARGET) all: $(TARGET)
$(TARGET): main.c $(TARGET): main.c vocabulary.c vocabulary.h
$(CC) $(CFLAGS) -o $(TARGET) main.c $(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: clean:
rm -f $(TARGET) rm -f $(TARGET) test_vocabulary *.gcda *.gcno *.gcov coverage.info
.PHONY: all clean .PHONY: all run clean test coverage

View File

@ -1,282 +1,36 @@
/* /*
* Vocabulary Learning Curve Analyzer * Vocabulary Learning Curve Analyzer - thin driver.
*
* 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
*/ */
#include <ctype.h> #include "vocabulary.h"
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.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 */ /* 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++) for (int i = start; i < start + length; i++)
{ {
if (i > start) if (i > start)
printf(" "); printf(" ");
printf("%s", word_sequence[i]->word); printf("%s", ctx->word_sequence[i]->word);
} }
} }
/* Print words needed (sorted by rank) */ /* 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 WordEntry *unique_entries[MAX_UNIQUE_WORDS];
static bool seen_rank[MAX_UNIQUE_WORDS + 1]; 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; int count = 0;
for (int i = start; i < start + length; i++) for (int i = start; i < start + length; i++)
{ {
WordEntry *entry = word_sequence[i]; WordEntry *entry = ctx->word_sequence[i];
if (!seen_rank[entry->rank]) if (!seen_rank[entry->rank])
{ {
seen_rank[entry->rank] = true; 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 i = 0; i < count - 1; i++)
{ {
for (int j = i + 1; j < count; j++) 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++) for (int i = 0; i < count; i++)
{ {
if (i > 0) if (i > 0)
@ -308,44 +60,38 @@ static void print_words_needed(int start, int length)
} }
/* Print results */ /* 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("======================================================================\n");
printf("VOCABULARY LEARNING CURVE\n"); printf("VOCABULARY LEARNING CURVE\n");
printf("======================================================================\n"); printf("======================================================================\n");
printf("\n"); printf("\n");
printf("For each excerpt length, the minimum number of top-frequency\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("\n");
printf("Total words in text: %d\n", num_words); printf("Total words in text: %d\n", ctx->num_words);
printf("Unique words: %d\n", num_unique_words); printf("Unique words: %d\n", ctx->num_unique_words);
printf("\n"); printf("\n");
printf("----------------------------------------------------------------------\n"); printf("----------------------------------------------------------------------\n");
int prev_vocab = 0; int prev_vocab = 0;
int actual_max = max_length; int actual_max = max_length;
if (actual_max > num_words) if (actual_max > ctx->num_words)
actual_max = num_words; actual_max = ctx->num_words;
for (int i = 0; i < actual_max; i++) for (int i = 0; i < actual_max; i++)
{ {
ExcerptResult *r = &results[i]; ExcerptResult *r = &results[i];
printf("\n[Length %d] Vocab needed: %d", r->excerpt_length, r->min_vocab_needed); printf("\n[Length %d] Vocab needed: %d", r->excerpt_length, r->min_vocab_needed);
if (r->min_vocab_needed > prev_vocab) if (r->min_vocab_needed > prev_vocab)
{
printf(" (+%d)", r->min_vocab_needed - prev_vocab); printf(" (+%d)", r->min_vocab_needed - prev_vocab);
}
printf("\n"); printf("\n");
printf(" Excerpt: \""); printf(" Excerpt: \"");
print_excerpt(r->start_pos, r->excerpt_length); print_excerpt(ctx, r->start_pos, r->excerpt_length);
printf("\"\n"); printf("\"\n");
printf(" Words: "); printf(" Words: ");
print_words_needed(r->start_pos, r->excerpt_length); print_words_needed(ctx, r->start_pos, r->excerpt_length);
printf("\n"); printf("\n");
prev_vocab = r->min_vocab_needed; prev_vocab = r->min_vocab_needed;
} }
@ -359,63 +105,26 @@ static void print_results(ExcerptResult *results, int max_length)
} }
} }
/* Free memory */ static void dump_vocabulary(const VocabContext *ctx, int max_rank)
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)
{ {
printf("VOCAB_DUMP_START\n"); 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) if (ctx->all_entries[i]->rank <= max_rank)
{ printf("%s;%d\n", ctx->all_entries[i]->word, ctx->all_entries[i]->rank);
printf("%s;%d\n", all_entries[i]->word, all_entries[i]->rank);
}
} }
printf("VOCAB_DUMP_END\n"); printf("VOCAB_DUMP_END\n");
} }
/* Find longest excerpt using only top N words (inverse mode) */ static void print_longest_excerpt_result(const VocabContext *ctx, int max_vocab, int best_start,
static void find_longest_excerpt(int max_vocab) 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("======================================================================\n");
printf("INVERSE MODE: LONGEST EXCERPT WITH TOP %d WORDS\n", max_vocab); printf("INVERSE MODE: LONGEST EXCERPT WITH TOP %d WORDS\n", max_vocab);
printf("======================================================================\n"); printf("======================================================================\n");
printf("\n"); printf("\n");
printf("Total words in text: %d\n", num_words); printf("Total words in text: %d\n", ctx->num_words);
printf("Unique words: %d\n", num_unique_words); printf("Unique words: %d\n", ctx->num_unique_words);
printf("Vocabulary limit: top %d words\n", max_vocab); printf("Vocabulary limit: top %d words\n", max_vocab);
printf("\n"); printf("\n");
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("Position: words %d to %d\n", best_start + 1, best_start + best_length);
printf("\n"); printf("\n");
printf("Excerpt:\n \""); printf("Excerpt:\n \"");
print_excerpt(best_start, best_length); print_excerpt(ctx, best_start, best_length);
printf("\"\n"); printf("\"\n");
printf("\n"); printf("\n");
/* Find the rarest word in the excerpt */
int max_rank_used = 0; int max_rank_used = 0;
const char *rarest_word = NULL; const char *rarest_word = NULL;
for (int i = best_start; i < best_start + best_length; i++) 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; max_rank_used = ctx->word_sequence[i]->rank;
rarest_word = word_sequence[i]->word; rarest_word = ctx->word_sequence[i]->word;
} }
} }
// cppcheck-suppress nullPointer // cppcheck-suppress nullPointer
printf("Rarest word used: %s (#%d)\n", rarest_word, max_rank_used); 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]; 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; int unique_count = 0;
for (int i = best_start; i < best_start + best_length; i++) 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++; unique_count++;
} }
} }
@ -492,19 +199,16 @@ int main(int argc, char *argv[])
int max_length = 30; int max_length = 30;
bool dump_vocab = false; bool dump_vocab = false;
int dump_max_rank = 0; 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++) for (int i = 2; i < argc; i++)
{ {
if (strcmp(argv[i], "--dump-vocab") == 0) if (strcmp(argv[i], "--dump-vocab") == 0)
{ {
dump_vocab = true; dump_vocab = true;
if (i + 1 < argc && argv[i + 1][0] != '-') if (i + 1 < argc && argv[i + 1][0] != '-')
{
dump_max_rank = atoi(argv[++i]); dump_max_rank = atoi(argv[++i]);
} }
}
else if (strcmp(argv[i], "--max-vocab") == 0) else if (strcmp(argv[i], "--max-vocab") == 0)
{ {
if (i + 1 < argc) if (i + 1 < argc)
@ -532,72 +236,73 @@ int main(int argc, char *argv[])
} }
} }
/* Initialize hash table */ VocabContext ctx;
memset(hash_table, 0, sizeof(hash_table)); vocab_init(&ctx);
/* Process file */ FILE *fp = fopen(filename, "r");
if (!process_file(filename)) if (!fp)
{ {
fprintf(stderr, "Cannot open file: %s\n", filename);
return 1; 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"); fprintf(stderr, "No words found in file\n");
vocab_cleanup(&ctx);
return 1; return 1;
} }
/* Assign ranks by frequency */ vocab_assign_ranks(&ctx);
assign_ranks();
/* Inverse mode: find longest excerpt with limited vocabulary */
if (max_vocab_mode > 0) 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_vocab)
{ {
if (dump_max_rank == 0) if (dump_max_rank == 0)
dump_max_rank = max_vocab_mode; dump_max_rank = max_vocab_mode;
dump_vocabulary(dump_max_rank); dump_vocabulary(&ctx, dump_max_rank);
} }
cleanup(); vocab_cleanup(&ctx);
return 0; return 0;
} }
/* Normal mode: find optimal excerpts */
ExcerptResult *results = malloc(max_length * sizeof(ExcerptResult)); ExcerptResult *results = malloc(max_length * sizeof(ExcerptResult));
if (!results) if (!results)
{ {
fprintf(stderr, "Memory allocation failed\n"); fprintf(stderr, "Memory allocation failed\n");
cleanup(); vocab_cleanup(&ctx);
return 1; 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 (dump_vocab)
{ {
/* If no max_rank specified, use the max from the excerpt */
if (dump_max_rank == 0 && max_length > 0) if (dump_max_rank == 0 && max_length > 0)
{
dump_max_rank = results[max_length - 1].min_vocab_needed; dump_max_rank = results[max_length - 1].min_vocab_needed;
}
if (dump_max_rank > 0) if (dump_max_rank > 0)
{ dump_vocabulary(&ctx, dump_max_rank);
dump_vocabulary(dump_max_rank);
}
} }
/* Cleanup */
free(results); free(results);
cleanup(); vocab_cleanup(&ctx);
return 0; return 0;
} }

View 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;
}

View 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;
}

View 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.

View File

@ -18,6 +18,54 @@ reverseString: reverseString.cpp
solveQuadraticEquation: solveQuadraticEquation.cpp solveQuadraticEquation: solveQuadraticEquation.cpp
$(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS) $(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 run: all
./howOftenDoesCharOccur ./howOftenDoesCharOccur
./quickchallenges ./quickchallenges
@ -25,6 +73,6 @@ run: all
./solveQuadraticEquation ./solveQuadraticEquation
clean: 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

View File

@ -1,11 +1,7 @@
#include "howOftenDoesCharOccur.h"
#include <iostream> #include <iostream>
#include <vector> #include <vector>
struct charOccurence {
char c;
int occurrence;
};
void printCharOccurenceVector(const std::vector<charOccurence> v) { void printCharOccurenceVector(const std::vector<charOccurence> v) {
std::cout << "["; std::cout << "[";
for (unsigned int i = 0; i < v.size(); i++) { for (unsigned int i = 0; i < v.size(); i++) {
@ -15,9 +11,9 @@ void printCharOccurenceVector(const std::vector<charOccurence> v) {
std::cout << "]" << std::endl; std::cout << "]" << std::endl;
} }
int main() { std::vector<charOccurence> computeCharOccurences(const std::string &userInput) {
std::vector<charOccurence> list; std::vector<charOccurence> list;
std::string userInput = "aaaabbbcca"; if (!userInput.empty()) {
charOccurence newCharOccurence; charOccurence newCharOccurence;
newCharOccurence.c = userInput.at(0); newCharOccurence.c = userInput.at(0);
newCharOccurence.occurrence = 1; newCharOccurence.occurrence = 1;
@ -28,12 +24,21 @@ int main() {
j = 1; j = 1;
newCharOccurence.c = newCharacter; newCharOccurence.c = newCharacter;
newCharOccurence.occurrence = j; newCharOccurence.occurrence = j;
} else { } else {
newCharOccurence.occurrence++; 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); printCharOccurenceVector(list);
return 0; return 0;
} }
#endif

View 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);

View File

@ -1,3 +1,4 @@
#include "quickchallenges.h"
#include <iostream> #include <iostream>
#include <vector> #include <vector>
@ -9,6 +10,7 @@ int sumStartEnd(int start, int end) {
return sum; return sum;
} }
#ifndef TESTING
int main() { int main() {
std::cout << "Krzysztof" << std::endl; std::cout << "Krzysztof" << std::endl;
for (int i = 700; i >= 200; i -= 13) for (int i = 700; i >= 200; i -= 13)
@ -97,3 +99,4 @@ int main() {
else else
std::cout << GRADES[3]; std::cout << GRADES[3];
} }
#endif

View File

@ -0,0 +1,3 @@
#pragma once
int sumStartEnd(int start, int end);

View File

@ -1,20 +1,29 @@
#include "reverseString.h"
#include <algorithm> #include <algorithm>
#include <iostream> #include <iostream>
#include <string> #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() { int main() {
std::string userString; std::string userString;
getline(std::cin, userString); getline(std::cin, userString);
int sLength = userString.length(); std::string tempString = reverseStringManual(userString);
std::string tempString = userString; std::string stdReversed = userString;
for (int i = 0; i < sLength / 2; i++) { reverse(stdReversed.begin(), stdReversed.end());
char temp = tempString[sLength - 1 - i]; bool correct = tempString == stdReversed;
tempString[sLength - 1 - i] = tempString[i];
tempString[i] = temp;
}
reverse(userString.begin(), userString.end());
bool correct = tempString == userString;
std::cout << correct << std::endl; std::cout << correct << std::endl;
std::cout << tempString << std::endl; std::cout << tempString << std::endl;
return 0; return 0;
} }
#endif

View File

@ -0,0 +1,4 @@
#pragma once
#include <string>
std::string reverseStringManual(const std::string &s);

View File

@ -1,3 +1,4 @@
#include "solveQuadraticEquation.h"
#include <iostream> #include <iostream>
#include <math.h> #include <math.h>
#include <string> #include <string>
@ -18,6 +19,7 @@ float calculateSecondTerm(float a, float b, float delta) {
return (-b + sqrt(delta)) / (2 * a); return (-b + sqrt(delta)) / (2 * a);
} }
#ifndef TESTING
int main() { int main() {
print(START); print(START);
float a, b, c; float a, b, c;
@ -37,3 +39,4 @@ int main() {
std::cout << "x_2 = " << x_2 << std::endl; std::cout << "x_2 = " << x_2 << std::endl;
return 0; return 0;
} }
#endif

View 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);

View 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;
}

File diff suppressed because it is too large Load Diff

View File

@ -7,17 +7,24 @@
"dev": "vite", "dev": "vite",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "vite build", "build": "vite build",
"preview": "vite preview --strictPort --port 5173" "preview": "vite preview --strictPort --port 5173",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "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": "^18.3.5",
"@types/react-dom": "^18.3.0", "@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", "typescript": "^5.5.4",
"vite": "^5.4.1" "vite": "^5.4.1",
"vitest": "^4.1.4"
} }
} }

View 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()
})
})

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View 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)
})
})

View File

@ -72,15 +72,13 @@ export function useBattery() {
battery?.removeEventListener?.('levelchange', onChange) battery?.removeEventListener?.('levelchange', onChange)
battery?.removeEventListener?.('chargingtimechange', onChange) battery?.removeEventListener?.('chargingtimechange', onChange)
battery?.removeEventListener?.('dischargingtimechange', onChange) battery?.removeEventListener?.('dischargingtimechange', onChange)
if (!battery?.removeEventListener) { if (battery && !battery.removeEventListener) {
if (battery) {
battery.onchargingchange = null battery.onchargingchange = null
battery.onlevelchange = null battery.onlevelchange = null
battery.onchargingtimechange = null battery.onchargingtimechange = null
battery.ondischargingtimechange = null battery.ondischargingtimechange = null
} }
} }
}
setLoading(false) setLoading(false)
} catch (e: unknown) { } catch (e: unknown) {

View 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()
})
})

View File

@ -14,6 +14,6 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"types": [] "types": ["vitest/globals"]
} }
} }

View File

@ -6,5 +6,21 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
open: false 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
}
}
} }
}) })

File diff suppressed because it is too large Load Diff

View File

@ -7,27 +7,36 @@
"dev": "concurrently \"vite\" \"npm:server:dev\"", "dev": "concurrently \"vite\" \"npm:server:dev\"",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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": { "dependencies": {
"axios": "^1.7.2", "axios": "^1.7.2",
"dotenv": "^16.4.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@testing-library/jest-dom": "^6.9.1",
"@types/express": "^4.17.21", "@testing-library/react": "^16.3.2",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.12.12", "@types/node": "^20.12.12",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@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", "concurrently": "^8.2.2",
"jsdom": "^29.0.2",
"supertest": "^7.2.2",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.3.3" "vite": "^5.3.3",
"vitest": "^4.1.4"
} }
} }

View 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}`);
});

View 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('*');
});
});
});

View File

@ -5,8 +5,7 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
const PORT = Number(process.env.PORT || 8787); export const API_BASE = 'https://api.football-data.org/v4';
const API_BASE = 'https://api.football-data.org/v4';
const API_TOKEN = process.env.FOOTBALL_DATA_API_KEY; const API_TOKEN = process.env.FOOTBALL_DATA_API_KEY;
if (!API_TOKEN) { if (!API_TOKEN) {
@ -29,7 +28,6 @@ app.use((req, res, next) => {
const start = process.hrtime.bigint(); const start = process.hrtime.bigint();
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const MAX_LOG_BODY = 2000; // chars 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 // Attach id so downstream handlers could use it if needed
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
(res as any).json = (body: unknown) => { (res as any).json = (body: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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); return originalJson(body);
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(res as any).send = (body: unknown) => { (res as any).send = (body: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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); 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', () => { res.on('finish', () => {
const durMs = Number(process.hrtime.bigint() - start) / 1_000_000; 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; const body = (res as any).locals?.bodyForLog;
if (body !== undefined) { if (body !== undefined) {
const str = typeof body === 'string' ? body : JSON.stringify(body); const str = typeof body === 'string' ? body : JSON.stringify(body);
bodyPreview = ` body=${clip(str)}`; bodyPreview = ` body=${clipStr(str, MAX_LOG_BODY)}`;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
@ -73,41 +71,41 @@ app.use((req, res, next) => {
}); });
// Axios interceptors to log outgoing requests and incoming responses // 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() };
// 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}`); console.log(`[axios ->] ${String(config.method || 'GET').toUpperCase()} ${config.url}`);
return config; return config;
}, }
(error) => {
export function axiosRequestOnRejected(error: { message?: string }) {
console.warn('[axios req error]', error?.message || error); console.warn('[axios req error]', error?.message || error);
return Promise.reject(error); return Promise.reject(error);
} }
);
axios.interceptors.response.use( export function clipStr(s: string, max: number) {
(response) => { return s && s.length > max ? `${s.slice(0, max)}…(+${s.length - max})` : s;
// eslint-disable-next-line @typescript-eslint/no-explicit-any }
const started = (response.config as any).metadata?.start || Date.now();
// 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; const dur = Date.now() - started;
let dataStr = ''; let dataStr = '';
try { try {
dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data); dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
} catch { /* ignore */ } } catch { /* ignore */ }
const size = dataStr?.length || 0; 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);
console.log(`[axios <-] ${response.status} ${String(response.config.method || 'GET').toUpperCase()} ${response.config.url} ${dur}ms ~${size}B data=${clip(dataStr)}`); console.log(`[axios <-] ${response.status} ${String(response.config.method || 'GET').toUpperCase()} ${response.config.url} ${dur}ms ~${size}B data=${clipStr(dataStr, 2000)}`);
return response; return response;
}, }
(error) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function axiosResponseOnRejected(error: any) {
const cfg = error?.config || {}; const cfg = error?.config || {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any const started = cfg?.metadata?.start || Date.now();
const started = (cfg as any).metadata?.start || Date.now();
const dur = Date.now() - started; const dur = Date.now() - started;
const status = error?.response?.status; const status = error?.response?.status;
let dataStr = ''; let dataStr = '';
@ -115,24 +113,24 @@ axios.interceptors.response.use(
const d = error?.response?.data; const d = error?.response?.data;
dataStr = typeof d === 'string' ? d : JSON.stringify(d); dataStr = typeof d === 'string' ? d : JSON.stringify(d);
} catch { /* ignore */ } } 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);
console.warn(`[axios ! ] ${status ?? 'ERR'} ${String(cfg.method || 'GET').toUpperCase()} ${cfg.url} ${dur}ms data=${dataStr ? clip(dataStr) : (error?.message || 'error')}`); 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); 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 })); app.get('/health', (_req: Request, res: Response) => res.json({ ok: true }));
function buildHeaders() { export function buildHeaders() {
return { return {
'X-Auth-Token': API_TOKEN || '', 'X-Auth-Token': API_TOKEN || '',
} as Record<string, string>; } as Record<string, string>;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function normalizeMatch(m: Record<string, any>) { export function normalizeMatch(m: Record<string, any>) {
return { return {
id: m.id, id: m.id,
utcDate: m.utcDate, utcDate: m.utcDate,
@ -210,7 +208,4 @@ app.get('/api/matches', async (req: Request, res: Response) => {
} }
}); });
app.listen(PORT, () => { export { app };
console.log(`[server] Listening on http://localhost:${PORT}`);
});

View 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);
});
});

View File

@ -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 = { export type ApiResponse = {
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 = {
count: number; count: number;
matches: Match[]; matches: Match[];
fetchedAt: string; 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() { export default function App() {
const fetchLive = useCallback(() => fetchJson<ApiResponse>('http://localhost:8787/api/live', { headers: { 'cache-control': 'no-cache' } }), []); 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' } }), []); const fetchToday = useCallback(() => fetchJson<ApiResponse>('http://localhost:8787/api/matches', { headers: { 'cache-control': 'no-cache' } }), []);

View 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();
});
});

View 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>
);
}

View 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();
}
});
});

View 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();
}

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View 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();
});
});

View 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;
}

View File

@ -12,7 +12,8 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true,
"types": ["vitest/globals"]
}, },
"include": ["src"] "include": ["src", "server/src"]
} }

View File

@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
@ -9,5 +10,22 @@ export default defineConfig({
}, },
preview: { preview: {
port: 5173 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

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,9 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"test:vitest": "vitest run",
"test:coverage": "vitest run --coverage"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@ -29,12 +31,14 @@
"@angular/cli": "^17.0.3", "@angular/cli": "^17.0.3",
"@angular/compiler-cli": "^17.0.0", "@angular/compiler-cli": "^17.0.0",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@vitest/coverage-v8": "^4.1.4",
"jasmine-core": "~5.1.0", "jasmine-core": "~5.1.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.2.2" "typescript": "~5.2.2",
"vitest": "^4.1.4"
} }
} }

View File

View 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);
});
});

View File

@ -1,20 +1,31 @@
import { Component } from '@angular/core'; import { Component } from "@angular/core";
import { CommonModule } from '@angular/common'; import { CommonModule } from "@angular/common";
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from "@angular/router";
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from "@angular/material/input";
import {MatButtonModule} from '@angular/material/button'; import { MatButtonModule } from "@angular/material/button";
import { FormsModule } from '@angular/forms'; import { FormsModule } from "@angular/forms";
import {
findValidPairs,
findCorrespondingValue,
changeIndex,
} from "./pair-logic";
@Component({ @Component({
selector: 'app-root', selector: "app-root",
standalone: true, standalone: true,
imports: [CommonModule, RouterOutlet, MatInputModule, FormsModule, MatButtonModule], imports: [
templateUrl: './app.component.html', CommonModule,
styleUrls: ['./app.component.scss'] RouterOutlet,
MatInputModule,
FormsModule,
MatButtonModule,
],
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
}) })
export class AppComponent { export class AppComponent {
inputOne: number | null = null; // Default initialization to 50 inputOne: number | null = null;
inputTwo: number | null = null; // Default initialization to 50 inputTwo: number | null = null;
min: number | null = 100; min: number | null = 100;
max: number | null = 250; max: number | null = 250;
step: number | null = 25; step: number | null = 25;
@ -24,115 +35,101 @@ export class AppComponent {
possibleValues: Array<[number, number]> | null = []; possibleValues: Array<[number, number]> | null = [];
constructor() { 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() { 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() { 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) { private doChangeIndex(currentValue: number, direction: boolean) {
this.possibleValues = AppComponent.findValidPairs(this.step, this.min, this.max, this.targetValue); this.possibleValues = findValidPairs(
this.step,
this.min,
this.max,
this.targetValue,
);
const length = this.possibleValues?.length; const length = this.possibleValues?.length;
if(typeof length !== "undefined") { return changeIndex(currentValue, direction, length);
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;
} }
updateTwoValue() { updateTwoValue() {
if(this.possibleValues !== null) { if (this.possibleValues !== null) {
this.inputOne = this.possibleValues[this.indexOne][0]; this.inputOne = this.possibleValues[this.indexOne][0];
if(typeof this.inputOne !== "undefined" && this.inputOne !== null) { if (typeof this.inputOne !== "undefined" && this.inputOne !== null) {
const result = AppComponent.findCorrespondingValue(this.possibleValues, this.inputOne); const result = findCorrespondingValue(
if(result !== null) { this.possibleValues,
this.inputOne,
);
if (result !== null) {
[this.inputTwo, this.indexTwo] = result; [this.inputTwo, this.indexTwo] = result;
return; 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() { upOne() {
this.indexOne = this.changeIndex(this.indexOne, true); this.indexOne = this.doChangeIndex(this.indexOne, true);
this.updateTwoValue(); this.updateTwoValue();
} }
downOne() { downOne() {
this.indexOne = this.changeIndex(this.indexOne, false); this.indexOne = this.doChangeIndex(this.indexOne, false);
this.updateTwoValue(); this.updateTwoValue();
} }
upTwo() { upTwo() {
this.indexTwo = this.changeIndex(this.indexTwo, true); this.indexTwo = this.doChangeIndex(this.indexTwo, true);
if(this.possibleValues !== null) { if (this.possibleValues !== null) {
this.inputTwo = this.possibleValues[this.indexTwo][1]; this.inputTwo = this.possibleValues[this.indexTwo][1];
const result = AppComponent.findCorrespondingValue(this.possibleValues, this.inputTwo); const result = findCorrespondingValue(
if(result !== null) { this.possibleValues,
this.inputTwo,
);
if (result !== null) {
[this.inputOne, this.indexOne] = result; [this.inputOne, this.indexOne] = result;
} }
} }
} }
downTwo() { downTwo() {
this.indexTwo = this.changeIndex(this.indexTwo, false); this.indexTwo = this.doChangeIndex(this.indexTwo, false);
if(this.possibleValues !== null) { if (this.possibleValues !== null) {
this.inputTwo = this.possibleValues[this.indexTwo][1]; this.inputTwo = this.possibleValues[this.indexTwo][1];
const result = AppComponent.findCorrespondingValue(this.possibleValues, this.inputTwo); const result = findCorrespondingValue(
if(result !== null) { this.possibleValues,
this.inputTwo,
);
if (result !== null) {
[this.inputOne, this.indexOne] = result; [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;
}
} }

View 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();
});
});

View 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;
}

View 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,
},
},
},
});

View File

@ -1,28 +1,177 @@
# This file configures the analyzer, which statically analyzes Dart code to analyzer:
# check for errors, warnings, and lints. errors:
# missing_return: error
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled missing_required_param: error
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be todo: warning
# invoked from the command line by running `flutter analyze`. language:
strict-casts: true
# The following line activates a set of recommended lints for Flutter apps, strict-inference: true
# packages, and plugins designed to encourage good coding practices. strict-raw-types: true
include: package:flutter_lints/flutter.yaml
linter: 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: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # Error rules
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule always_use_package_imports: true
avoid_dynamic_calls: true
# Additional information about this file can be found at avoid_slow_async_io: true
# https://dart.dev/guides/language/analysis-options 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

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'screens/pomodoro_screen.dart'; import 'package:pomodoro_app/screens/pomodoro_screen.dart';
import 'theme/pomodoro_theme.dart'; import 'package:pomodoro_app/theme/pomodoro_theme.dart';
void main() { void main() {
runApp(const PomodoroApp()); runApp(const PomodoroApp());
@ -13,12 +13,10 @@ class PomodoroApp extends StatelessWidget {
const PomodoroApp({super.key}); const PomodoroApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => MaterialApp(
return MaterialApp(
title: 'Pomodoro', title: 'Pomodoro',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: PomodoroTheme.darkTheme, theme: PomodoroTheme.darkTheme,
home: const PomodoroScreen(), home: const PomodoroScreen(),
); );
}
} }

View File

@ -1,3 +1,5 @@
import 'package:meta/meta.dart';
/// Defines the timer style (technique) the user can choose. /// Defines the timer style (technique) the user can choose.
enum TimerStyle { enum TimerStyle {
/// Classic Pomodoro: 25 min work, 5 min short break, 15 min long break. /// 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 snapshot of the Pomodoro timer state.
@immutable
class PomodoroState { class PomodoroState {
/// Creates a [PomodoroState]. /// Creates a [PomodoroState].
const PomodoroState({ const PomodoroState({
@ -102,8 +105,6 @@ class PomodoroState {
/// Creates the default initial state. /// Creates the default initial state.
factory PomodoroState.initial({ factory PomodoroState.initial({
int workMinutes = 25, int workMinutes = 25,
int shortBreakMinutes = 5,
int longBreakMinutes = 15,
int pomodorosPerCycle = 4, int pomodorosPerCycle = 4,
}) { }) {
final totalSeconds = workMinutes * 60; final totalSeconds = workMinutes * 60;
@ -137,8 +138,8 @@ class PomodoroState {
/// Progress as a value between 0.0 and 1.0. /// Progress as a value between 0.0 and 1.0.
double get progress { double get progress {
if (totalSeconds == 0) return 1.0; if (totalSeconds == 0) return 1;
return 1.0 - (remainingSeconds / totalSeconds); return 1 - (remainingSeconds / totalSeconds);
} }
/// Display label for the current mode, context-aware. /// Display label for the current mode, context-aware.
@ -168,8 +169,8 @@ class PomodoroState {
bool? isRunning, bool? isRunning,
int? completedPomodoros, int? completedPomodoros,
int? pomodorosPerCycle, int? pomodorosPerCycle,
}) { }) =>
return PomodoroState( PomodoroState(
mode: mode ?? this.mode, mode: mode ?? this.mode,
remainingSeconds: remainingSeconds ?? this.remainingSeconds, remainingSeconds: remainingSeconds ?? this.remainingSeconds,
totalSeconds: totalSeconds ?? this.totalSeconds, totalSeconds: totalSeconds ?? this.totalSeconds,
@ -177,7 +178,6 @@ class PomodoroState {
completedPomodoros: completedPomodoros ?? this.completedPomodoros, completedPomodoros: completedPomodoros ?? this.completedPomodoros,
pomodorosPerCycle: pomodorosPerCycle ?? this.pomodorosPerCycle, pomodorosPerCycle: pomodorosPerCycle ?? this.pomodorosPerCycle,
); );
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@ -192,8 +192,7 @@ class PomodoroState {
} }
@override @override
int get hashCode { int get hashCode => Object.hash(
return Object.hash(
mode, mode,
remainingSeconds, remainingSeconds,
totalSeconds, totalSeconds,
@ -201,5 +200,4 @@ class PomodoroState {
completedPomodoros, completedPomodoros,
pomodorosPerCycle, pomodorosPerCycle,
); );
}
} }

View File

@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart'; import 'package:pomodoro_app/models/pomodoro_state.dart';
import '../services/pomodoro_timer.dart'; import 'package:pomodoro_app/services/notification_service.dart';
import '../services/notification_service.dart'; import 'package:pomodoro_app/services/pomodoro_timer.dart';
import '../services/sound_service.dart'; import 'package:pomodoro_app/services/sound_service.dart';
import '../services/sync_service.dart'; import 'package:pomodoro_app/services/sync_service.dart';
import '../widgets/pomodoro_indicators.dart'; import 'package:pomodoro_app/widgets/pomodoro_indicators.dart';
import '../widgets/timer_controls.dart'; import 'package:pomodoro_app/widgets/timer_controls.dart';
import '../widgets/timer_display.dart'; import 'package:pomodoro_app/widgets/timer_display.dart';
/// The main screen of the Pomodoro app. /// The main screen of the Pomodoro app.
/// ///
@ -42,7 +42,7 @@ class PomodoroScreenState extends State<PomodoroScreen> {
if (widget.timer != null) { if (widget.timer != null) {
// Test path: synchronous init, no sync service needed. // Test path: synchronous init, no sync service needed.
_timer = widget.timer!; _timer = widget.timer;
_syncService = widget.syncService; _syncService = widget.syncService;
_timer!.addListener(_onTimerChanged); _timer!.addListener(_onTimerChanged);
_initialized = true; _initialized = true;
@ -54,7 +54,7 @@ class PomodoroScreenState extends State<PomodoroScreen> {
Future<void> _initAsync() async { Future<void> _initAsync() async {
_syncService = SyncService( _syncService = SyncService(
onStateReceived: _onRemoteState, onStateReceived: onRemoteState,
); );
_ownsSyncService = true; _ownsSyncService = true;
await _syncService!.start(); await _syncService!.start();
@ -71,7 +71,9 @@ class PomodoroScreenState extends State<PomodoroScreen> {
if (mounted) setState(() {}); 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); _timer?.applyRemoteState(state, action);
} }

View File

@ -2,7 +2,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; 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. /// Sends desktop notifications showing Pomodoro timer status.
/// ///

View File

@ -2,10 +2,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/pomodoro_state.dart'; import 'package:pomodoro_app/models/pomodoro_state.dart';
import 'notification_service.dart'; import 'package:pomodoro_app/services/notification_service.dart';
import 'sound_service.dart'; import 'package:pomodoro_app/services/sound_service.dart';
import 'sync_service.dart'; import 'package:pomodoro_app/services/sync_service.dart';
/// Manages the Pomodoro timer logic, independent of UI framework. /// Manages the Pomodoro timer logic, independent of UI framework.
/// ///
@ -32,8 +32,6 @@ class PomodoroTimer extends ChangeNotifier {
_pomodorosPerCycle = pomodorosPerCycle ?? timerStyle.defaultPomodorosPerCycle; _pomodorosPerCycle = pomodorosPerCycle ?? timerStyle.defaultPomodorosPerCycle;
_state = PomodoroState.initial( _state = PomodoroState.initial(
workMinutes: _workMinutes, workMinutes: _workMinutes,
shortBreakMinutes: _shortBreakMinutes,
longBreakMinutes: _longBreakMinutes,
pomodorosPerCycle: _pomodorosPerCycle, pomodorosPerCycle: _pomodorosPerCycle,
); );
} }
@ -84,8 +82,6 @@ class PomodoroTimer extends ChangeNotifier {
_pomodorosPerCycle = style.defaultPomodorosPerCycle; _pomodorosPerCycle = style.defaultPomodorosPerCycle;
_state = PomodoroState.initial( _state = PomodoroState.initial(
workMinutes: _workMinutes, workMinutes: _workMinutes,
shortBreakMinutes: _shortBreakMinutes,
longBreakMinutes: _longBreakMinutes,
pomodorosPerCycle: _pomodorosPerCycle, pomodorosPerCycle: _pomodorosPerCycle,
); );
_notificationService?.cancel(); _notificationService?.cancel();

View File

@ -1,7 +1,7 @@
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.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. /// Plays notification sounds for Pomodoro timer transitions.
/// ///
@ -16,9 +16,12 @@ class SoundService {
/// Pass a custom [playCallback] for testing. /// Pass a custom [playCallback] for testing.
SoundService({ SoundService({
@visibleForTesting Future<void> Function(String assetPath)? playCallback, @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 Future<void> Function(String assetPath)? _playCallback;
final AudioPlayer Function() _playerFactory;
AudioPlayer? _player; AudioPlayer? _player;
bool _disposed = false; bool _disposed = false;
@ -41,8 +44,8 @@ class SoundService {
if (_playCallback != null) { if (_playCallback != null) {
await _playCallback(assetPath); await _playCallback(assetPath);
} else { } else {
_player?.dispose(); await _player?.dispose();
_player = AudioPlayer(); _player = _playerFactory();
await _player!.play(AssetSource('$_assetPrefix/$assetPath')); await _player!.play(AssetSource('$_assetPrefix/$assetPath'));
} }
debugPrint('SoundService: Playing $assetPath'); debugPrint('SoundService: Playing $assetPath');

View File

@ -6,7 +6,7 @@ import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.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. /// Callback type for receiving a synced [PomodoroState] and action name.
typedef SyncCallback = void Function(PomodoroState state, String action); typedef SyncCallback = void Function(PomodoroState state, String action);
@ -26,10 +26,12 @@ class SyncService {
required this.onStateReceived, required this.onStateReceived,
this.port = 41234, this.port = 41234,
@visibleForTesting String? deviceId, @visibleForTesting String? deviceId,
@visibleForTesting bool? isAndroid,
@visibleForTesting @visibleForTesting
Future<RawDatagramSocket> Function(dynamic host, int port)? Future<RawDatagramSocket> Function(Object host, int port)?
socketFactory, socketFactory,
}) : deviceId = deviceId ?? _generateDeviceId(), }) : deviceId = deviceId ?? _generateDeviceId(),
_isAndroid = isAndroid ?? Platform.isAndroid,
_socketFactory = socketFactory; _socketFactory = socketFactory;
/// Unique identifier for this device instance. /// Unique identifier for this device instance.
@ -45,8 +47,9 @@ class SyncService {
/// Called when a state update is received from another device. /// Called when a state update is received from another device.
final SyncCallback onStateReceived; final SyncCallback onStateReceived;
final Future<RawDatagramSocket> Function(dynamic host, int port)? final Future<RawDatagramSocket> Function(Object host, int port)?
_socketFactory; _socketFactory;
final bool _isAndroid;
RawDatagramSocket? _socket; RawDatagramSocket? _socket;
Timer? _heartbeat; Timer? _heartbeat;
@ -71,7 +74,6 @@ class SyncService {
_socket = await RawDatagramSocket.bind( _socket = await RawDatagramSocket.bind(
InternetAddress.anyIPv4, InternetAddress.anyIPv4,
port, port,
reuseAddress: true,
); );
} }
@ -80,7 +82,6 @@ class SyncService {
_socket?.listen( _socket?.listen(
_onSocketEvent, _onSocketEvent,
onError: _onError, onError: _onError,
cancelOnError: false,
); );
debugPrint('SyncService: Listening on port $port (device=$deviceId)'); debugPrint('SyncService: Listening on port $port (device=$deviceId)');
@ -115,10 +116,13 @@ class SyncService {
/// Starts periodic heartbeat that broadcasts current state. /// Starts periodic heartbeat that broadcasts current state.
/// ///
/// This keeps devices in sync even if an individual message is lost. /// 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?.cancel();
_heartbeat = Timer.periodic( _heartbeat = Timer.periodic(
const Duration(seconds: 5), interval,
(_) => broadcast(stateProvider(), 'heartbeat'), (_) => broadcast(stateProvider(), 'heartbeat'),
); );
} }
@ -198,8 +202,7 @@ class SyncService {
return utf8.encode(jsonEncode(map)); return utf8.encode(jsonEncode(map));
} }
static Map<String, dynamic> _encodeState(PomodoroState state) { static Map<String, dynamic> _encodeState(PomodoroState state) => {
return {
'mode': state.mode.name, 'mode': state.mode.name,
'remainingSeconds': state.remainingSeconds, 'remainingSeconds': state.remainingSeconds,
'totalSeconds': state.totalSeconds, 'totalSeconds': state.totalSeconds,
@ -207,10 +210,9 @@ class SyncService {
'completedPomodoros': state.completedPomodoros, 'completedPomodoros': state.completedPomodoros,
'pomodorosPerCycle': state.pomodorosPerCycle, 'pomodorosPerCycle': state.pomodorosPerCycle,
}; };
}
static PomodoroState _decodeState(Map<String, dynamic> map) { static PomodoroState _decodeState(Map<String, dynamic> map) =>
return PomodoroState( PomodoroState(
mode: PomodoroMode.values.byName(map['mode'] as String), mode: PomodoroMode.values.byName(map['mode'] as String),
remainingSeconds: map['remainingSeconds'] as int, remainingSeconds: map['remainingSeconds'] as int,
totalSeconds: map['totalSeconds'] as int, totalSeconds: map['totalSeconds'] as int,
@ -218,10 +220,9 @@ class SyncService {
completedPomodoros: map['completedPomodoros'] as int, completedPomodoros: map['completedPomodoros'] as int,
pomodorosPerCycle: map['pomodorosPerCycle'] as int, pomodorosPerCycle: map['pomodorosPerCycle'] as int,
); );
}
static Future<void> _acquireMulticastLock() async { Future<void> _acquireMulticastLock() async {
if (!Platform.isAndroid) return; if (!_isAndroid) return;
try { try {
await _methodChannel.invokeMethod<bool>('acquire'); await _methodChannel.invokeMethod<bool>('acquire');
} on MissingPluginException { } on MissingPluginException {
@ -231,8 +232,8 @@ class SyncService {
} }
} }
static Future<void> _releaseMulticastLock() async { Future<void> _releaseMulticastLock() async {
if (!Platform.isAndroid) return; if (!_isAndroid) return;
try { try {
await _methodChannel.invokeMethod<bool>('release'); await _methodChannel.invokeMethod<bool>('release');
} on MissingPluginException { } on MissingPluginException {

View File

@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; 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. /// Provides consistent theming for the Pomodoro app across platforms.
class PomodoroTheme { abstract final class PomodoroTheme {
PomodoroTheme._();
// Brand colors per mode. // Brand colors per mode.
static const Color workColor = Color(0xFFE74C3C); static const Color workColor = Color(0xFFE74C3C);
@ -29,8 +28,7 @@ class PomodoroTheme {
} }
/// The app's dark theme. /// The app's dark theme.
static ThemeData get darkTheme { static ThemeData get darkTheme => ThemeData(
return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: _darkBackground, scaffoldBackgroundColor: _darkBackground,
@ -69,5 +67,4 @@ class PomodoroTheme {
), ),
), ),
); );
}
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart'; import 'package:pomodoro_app/models/pomodoro_state.dart';
import '../theme/pomodoro_theme.dart'; import 'package:pomodoro_app/theme/pomodoro_theme.dart';
/// Shows completed pomodoro indicators as filled/unfilled dots. /// Shows completed pomodoro indicators as filled/unfilled dots.
class PomodoroIndicators extends StatelessWidget { class PomodoroIndicators extends StatelessWidget {
@ -15,8 +15,7 @@ class PomodoroIndicators extends StatelessWidget {
final PomodoroState state; final PomodoroState state;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => Row(
return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: List.generate( children: List.generate(
state.pomodorosPerCycle, state.pomodorosPerCycle,
@ -43,5 +42,4 @@ class PomodoroIndicators extends StatelessWidget {
}, },
), ),
); );
}
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart'; import 'package:pomodoro_app/models/pomodoro_state.dart';
import '../theme/pomodoro_theme.dart'; import 'package:pomodoro_app/theme/pomodoro_theme.dart';
/// Row of control buttons for the Pomodoro timer. /// Row of control buttons for the Pomodoro timer.
class TimerControls extends StatelessWidget { class TimerControls extends StatelessWidget {

View File

@ -2,8 +2,8 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/pomodoro_state.dart'; import 'package:pomodoro_app/models/pomodoro_state.dart';
import '../theme/pomodoro_theme.dart'; import 'package:pomodoro_app/theme/pomodoro_theme.dart';
/// A circular progress indicator that displays the remaining time. /// A circular progress indicator that displays the remaining time.
class TimerDisplay extends StatelessWidget { class TimerDisplay extends StatelessWidget {
@ -32,7 +32,7 @@ class TimerDisplay extends StatelessWidget {
// Background circle. // Background circle.
SizedBox.expand( SizedBox.expand(
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: 1.0, value: 1,
strokeWidth: 8, strokeWidth: 8,
color: color.withValues(alpha: 0.2), color: color.withValues(alpha: 0.2),
), ),

View File

@ -1,8 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/main.dart' as app;
import 'package:pomodoro_app/main.dart'; import 'package:pomodoro_app/main.dart';
void main() { 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 { testWidgets('PomodoroApp builds and shows MaterialApp', (tester) async {
await tester.pumpWidget(const PomodoroApp()); await tester.pumpWidget(const PomodoroApp());
expect(find.byType(MaterialApp), findsOneWidget); expect(find.byType(MaterialApp), findsOneWidget);

View File

@ -45,8 +45,6 @@ void main() {
test('creates state with custom durations', () { test('creates state with custom durations', () {
final state = PomodoroState.initial( final state = PomodoroState.initial(
workMinutes: 30, workMinutes: 30,
shortBreakMinutes: 10,
longBreakMinutes: 20,
pomodorosPerCycle: 3, pomodorosPerCycle: 3,
); );
expect(state.remainingSeconds, 30 * 60); expect(state.remainingSeconds, 30 * 60);

View File

@ -43,8 +43,8 @@ void main() {
late FakeTimerController fakeController; late FakeTimerController fakeController;
Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) { Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
fakeController = FakeTimerController(); fakeController = FakeTimerController()
fakeController._callback = callback; .._callback = callback;
return _FakeTimer(fakeController); return _FakeTimer(fakeController);
} }
@ -62,11 +62,9 @@ void main() {
timer.dispose(); timer.dispose();
}); });
Widget createApp() { Widget createApp() => MaterialApp(
return MaterialApp(
home: PomodoroScreen(timer: timer), home: PomodoroScreen(timer: timer),
); );
}
group('PomodoroScreen', () { group('PomodoroScreen', () {
testWidgets('shows initial time', (tester) async { testWidgets('shows initial time', (tester) async {
@ -132,8 +130,9 @@ void main() {
// Start and tick. // Start and tick.
await tester.tap(find.byIcon(Icons.play_arrow)); await tester.tap(find.byIcon(Icons.play_arrow));
await tester.pump(); await tester.pump();
fakeController.tick(); fakeController
fakeController.tick(); ..tick()
..tick();
await tester.pump(); await tester.pump();
// Reset. // Reset.
@ -203,5 +202,28 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('25:00'), findsOneWidget); 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);
});
}); });
} }

View File

@ -31,7 +31,7 @@ void main() {
}); });
test('showTimer sends Notify via gdbus', () async { test('showTimer sends Notify via gdbus', () async {
final state = PomodoroState( const state = PomodoroState(
mode: PomodoroMode.work, mode: PomodoroMode.work,
remainingSeconds: 1500, remainingSeconds: 1500,
totalSeconds: 1500, totalSeconds: 1500,
@ -53,7 +53,7 @@ void main() {
}); });
test('showTimer shows Start action when paused', () async { test('showTimer shows Start action when paused', () async {
final state = PomodoroState( const state = PomodoroState(
mode: PomodoroMode.shortBreak, mode: PomodoroMode.shortBreak,
remainingSeconds: 120, remainingSeconds: 120,
totalSeconds: 300, totalSeconds: 300,
@ -68,7 +68,7 @@ void main() {
}); });
test('showTimer replaces previous notification', () async { test('showTimer replaces previous notification', () async {
final state = PomodoroState( const state = PomodoroState(
mode: PomodoroMode.work, mode: PomodoroMode.work,
remainingSeconds: 1500, remainingSeconds: 1500,
totalSeconds: 1500, totalSeconds: 1500,
@ -96,9 +96,8 @@ void main() {
test('handles unparsable gdbus output gracefully', () async { test('handles unparsable gdbus output gracefully', () async {
final stubService = NotificationService( final stubService = NotificationService(
runProcess: (exec, args) async { runProcess: (exec, args) async =>
return ProcessResult(0, 0, 'unexpected output', ''); ProcessResult(0, 0, 'unexpected output', ''),
},
); );
final state = PomodoroState.initial(); final state = PomodoroState.initial();
@ -192,15 +191,38 @@ void main() {
errorService.dispose(); 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', () { group('progressBar', () {
test('returns empty bar at 0%', () { test('returns empty bar at 0%', () {
expect(NotificationService.progressBar(0.0), '' * 20); expect(NotificationService.progressBar(0), '' * 20);
}); });
test('returns full bar at 100%', () { test('returns full bar at 100%', () {
expect(NotificationService.progressBar(1.0), '' * 20); expect(NotificationService.progressBar(1), '' * 20);
}); });
test('returns half bar at 50%', () { test('returns half bar at 50%', () {

View File

@ -1,8 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/models/pomodoro_state.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/pomodoro_timer.dart';
import 'package:pomodoro_app/services/sound_service.dart';
/// A controllable fake timer for testing. /// A controllable fake timer for testing.
class FakeTimerController { class FakeTimerController {
@ -41,8 +44,8 @@ void main() {
late FakeTimerController fakeController; late FakeTimerController fakeController;
Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) { Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
fakeController = FakeTimerController(); fakeController = FakeTimerController()
fakeController._callback = callback; .._callback = callback;
return _FakeTimer(fakeController); return _FakeTimer(fakeController);
} }
@ -86,24 +89,25 @@ void main() {
}); });
test('does nothing if already running', () { test('does nothing if already running', () {
timer.start(); final stateAfterFirstStart = (timer..start()).state;
final stateAfterFirstStart = timer.state;
timer.start(); // second call timer.start(); // second call
expect(timer.state, stateAfterFirstStart); expect(timer.state, stateAfterFirstStart);
}); });
test('notifies listeners', () { test('notifies listeners', () {
var notified = false; var notified = false;
timer.addListener(() => notified = true); timer
timer.start(); ..addListener(() => notified = true)
..start();
expect(notified, true); expect(notified, true);
}); });
}); });
group('pause()', () { group('pause()', () {
test('sets isRunning to false', () { test('sets isRunning to false', () {
timer.start(); timer
timer.pause(); ..start()
..pause();
expect(timer.state.isRunning, false); expect(timer.state.isRunning, false);
}); });
@ -115,8 +119,9 @@ void main() {
test('preserves remaining time', () { test('preserves remaining time', () {
timer.start(); timer.start();
fakeController.tick(); // -1s fakeController
fakeController.tick(); // -1s ..tick() // -1s
..tick(); // -1s
timer.pause(); timer.pause();
expect(timer.state.remainingSeconds, 58); expect(timer.state.remainingSeconds, 58);
}); });
@ -133,8 +138,9 @@ void main() {
timer.start(); timer.start();
var count = 0; var count = 0;
timer.addListener(() => count++); timer.addListener(() => count++);
fakeController.tick(); fakeController
fakeController.tick(); ..tick()
..tick();
expect(count, 2); expect(count, 2);
}); });
}); });
@ -193,8 +199,9 @@ void main() {
group('reset()', () { group('reset()', () {
test('resets to full duration', () { test('resets to full duration', () {
timer.start(); timer.start();
fakeController.tick(); fakeController
fakeController.tick(); ..tick()
..tick();
timer.reset(); timer.reset();
expect(timer.state.remainingSeconds, 60); expect(timer.state.remainingSeconds, 60);
expect(timer.state.isRunning, false); expect(timer.state.isRunning, false);
@ -219,14 +226,16 @@ void main() {
}); });
test('skips from break to work', () { test('skips from break to work', () {
timer.skip(); // work -> short break timer
timer.skip(); // short break -> work ..skip() // work -> short break
..skip(); // short break -> work
expect(timer.state.mode, PomodoroMode.work); expect(timer.state.mode, PomodoroMode.work);
}); });
test('stops the timer when skipping', () { test('stops the timer when skipping', () {
timer.start(); timer
timer.skip(); ..start()
..skip();
expect(timer.state.isRunning, false); expect(timer.state.isRunning, false);
}); });
}); });
@ -234,15 +243,15 @@ void main() {
group('dispose()', () { group('dispose()', () {
test('cancels internal timer', () { test('cancels internal timer', () {
// Create a separate timer so tearDown does not double-dispose. // Create a separate timer so tearDown does not double-dispose.
final disposableTimer = PomodoroTimer( PomodoroTimer(
workMinutes: 1, workMinutes: 1,
shortBreakMinutes: 1, shortBreakMinutes: 1,
longBreakMinutes: 2, longBreakMinutes: 2,
pomodorosPerCycle: 2, pomodorosPerCycle: 2,
timerFactory: fakeTimerFactory, timerFactory: fakeTimerFactory,
); )
disposableTimer.start(); ..start()
disposableTimer.dispose(); ..dispose();
expect(fakeController.isActive, false); expect(fakeController.isActive, false);
}); });
}); });
@ -259,8 +268,9 @@ void main() {
}); });
test('switches back to pomodoro', () { test('switches back to pomodoro', () {
timer.switchStyle(TimerStyle.ultraradian); timer
timer.switchStyle(TimerStyle.pomodoro); ..switchStyle(TimerStyle.ultraradian)
..switchStyle(TimerStyle.pomodoro);
expect(timer.timerStyle, TimerStyle.pomodoro); expect(timer.timerStyle, TimerStyle.pomodoro);
expect(timer.state.remainingSeconds, 25 * 60); expect(timer.state.remainingSeconds, 25 * 60);
expect(timer.state.totalSeconds, 25 * 60); expect(timer.state.totalSeconds, 25 * 60);
@ -288,8 +298,9 @@ void main() {
test('notifies listeners', () { test('notifies listeners', () {
var notified = false; var notified = false;
timer.addListener(() => notified = true); timer
timer.switchStyle(TimerStyle.ultraradian); ..addListener(() => notified = true)
..switchStyle(TimerStyle.ultraradian);
expect(notified, true); expect(notified, true);
}); });
@ -316,7 +327,7 @@ void main() {
var notified = false; var notified = false;
timer.addListener(() => notified = true); timer.addListener(() => notified = true);
final remoteState = PomodoroState( const remoteState = PomodoroState(
mode: PomodoroMode.shortBreak, mode: PomodoroMode.shortBreak,
remainingSeconds: 200, remainingSeconds: 200,
totalSeconds: 300, totalSeconds: 300,
@ -334,7 +345,7 @@ void main() {
}); });
test('starts local ticking when remote state is running', () { test('starts local ticking when remote state is running', () {
final remoteState = PomodoroState( const remoteState = PomodoroState(
mode: PomodoroMode.work, mode: PomodoroMode.work,
remainingSeconds: 500, remainingSeconds: 500,
totalSeconds: 600, totalSeconds: 600,
@ -363,4 +374,109 @@ void main() {
expect(timer.state.isRunning, false); 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;
} }

View File

@ -1,7 +1,34 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/models/pomodoro_state.dart'; import 'package:pomodoro_app/models/pomodoro_state.dart';
import 'package:pomodoro_app/services/sound_service.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() { void main() {
group('SoundService', () { group('SoundService', () {
late List<String> playedAssets; late List<String> playedAssets;
@ -59,5 +86,56 @@ void main() {
); );
expect(playedAssets, isEmpty); 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();
});
}); });
} }

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/models/pomodoro_state.dart'; import 'package:pomodoro_app/models/pomodoro_state.dart';
import 'package:pomodoro_app/services/sync_service.dart'; import 'package:pomodoro_app/services/sync_service.dart';
@ -15,7 +16,7 @@ class FakeDatagramSocket implements RawDatagramSocket {
@override @override
int send(List<int> buffer, InternetAddress address, int port) { int send(List<int> buffer, InternetAddress address, int port) {
sentMessages.add(SentDatagram(buffer, address, port)); sentMessages.add(SentDatagram(buffer));
return buffer.length; return buffer.length;
} }
@ -25,27 +26,31 @@ class FakeDatagramSocket implements RawDatagramSocket {
/// Simulates receiving a datagram. /// Simulates receiving a datagram.
void injectDatagram(List<int> data, InternetAddress address, int port) { void injectDatagram(List<int> data, InternetAddress address, int port) {
_pendingDatagram = Datagram( _pendingDatagram = Datagram(
data as dynamic, Uint8List.fromList(data),
address, address,
port, port,
); );
_controller.add(RawSocketEvent.read); _controller.add(RawSocketEvent.read);
} }
/// Simulates a socket error.
void injectError(Object error) {
_controller.addError(error);
}
@override @override
StreamSubscription<RawSocketEvent> listen( StreamSubscription<RawSocketEvent> listen(
void Function(RawSocketEvent)? onData, { void Function(RawSocketEvent)? onData, {
Function? onError, Function? onError,
void Function()? onDone, void Function()? onDone,
bool? cancelOnError, bool? cancelOnError,
}) { }) =>
return _controller.stream.listen( _controller.stream.listen(
onData, onData,
onError: onError, onError: onError,
onDone: onDone, onDone: onDone,
cancelOnError: cancelOnError ?? false, cancelOnError: cancelOnError ?? false,
); );
}
@override @override
void joinMulticast(InternetAddress group, [NetworkInterface? interface_]) {} void joinMulticast(InternetAddress group, [NetworkInterface? interface_]) {}
@ -62,16 +67,16 @@ class FakeDatagramSocket implements RawDatagramSocket {
} }
class SentDatagram { class SentDatagram {
SentDatagram(this.data, this.address, this.port); SentDatagram(this.data);
final List<int> data; final List<int> data;
final InternetAddress address;
final int port;
Map<String, dynamic> get decoded => Map<String, dynamic> get decoded =>
jsonDecode(utf8.decode(data)) as Map<String, dynamic>; jsonDecode(utf8.decode(data)) as Map<String, dynamic>;
} }
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SyncService', () { group('SyncService', () {
late FakeDatagramSocket fakeSocket; late FakeDatagramSocket fakeSocket;
late SyncService service; late SyncService service;
@ -113,8 +118,9 @@ void main() {
final decoded = fakeSocket.sentMessages.first.decoded; final decoded = fakeSocket.sentMessages.first.decoded;
expect(decoded['deviceId'], 'test-device-1'); expect(decoded['deviceId'], 'test-device-1');
expect(decoded['action'], 'start'); expect(decoded['action'], 'start');
expect(decoded['state']['mode'], 'work'); final stateMap = decoded['state'] as Map<String, dynamic>;
expect(decoded['state']['remainingSeconds'], 25 * 60); expect(stateMap['mode'], 'work');
expect(stateMap['remainingSeconds'], 25 * 60);
}); });
test('ignores own messages', () async { test('ignores own messages', () async {
@ -195,12 +201,9 @@ void main() {
test('heartbeat sends periodic state', () async { test('heartbeat sends periodic state', () async {
final state = PomodoroState.initial(); final state = PomodoroState.initial();
service.startHeartbeat(() => state); service
..startHeartbeat(() => state)
// Wait for at least one heartbeat interval. ..stopHeartbeat();
// Note: In tests, Timer.periodic fires based on the test framework.
// We just verify it doesn't crash and can be stopped.
service.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');
}
} }

View 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,
);
});
});
}

View File

@ -0,0 +1 @@
"""FM24 Database Searcher — hybrid binary + HTML player database tool."""

View 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()

View 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()]

View 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

View 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)]

File diff suppressed because it is too large Load Diff

View 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

View 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())

View File

@ -0,0 +1 @@
"""Tests for FM24 Database Searcher."""

View 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"

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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("&amp; &lt;") == "& <"
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