|
1 | 1 | import { describe, it, expect } from 'vitest'; |
2 | | -import { calculateStreak, calculateMonthlyStats } from './calculate'; |
| 2 | +import { |
| 3 | + calculateStreak, |
| 4 | + calculateMonthlyStats, |
| 5 | + calculateWrappedStats, |
| 6 | + aggregateCalendars, |
| 7 | +} from './calculate'; |
3 | 8 | import type { ContributionCalendar } from '../types'; |
4 | 9 |
|
5 | 10 | // Turns a flat array of daily counts into the ContributionCalendar shape, |
@@ -386,28 +391,197 @@ describe('calculateStreak — empty and sparse year edge cases', () => { |
386 | 391 | expect(result.totalContributions).toBe(2); |
387 | 392 | }); |
388 | 393 | }); |
389 | | -it('handles different week start days correctly', () => { |
390 | | - // Simulating 2 full weeks (Sunday start vs Monday start shouldn't break logic) |
391 | | - const calendar = buildCalendar([ |
392 | | - 1, |
393 | | - 1, |
394 | | - 1, |
395 | | - 1, |
396 | | - 1, |
397 | | - 1, |
398 | | - 1, // week 1 |
399 | | - 1, |
400 | | - 1, |
401 | | - 1, |
402 | | - 1, |
403 | | - 1, |
404 | | - 1, |
405 | | - 1, // week 2 |
406 | | - ]); |
407 | | - |
408 | | - const result = calculateStreak(calendar); |
409 | | - |
410 | | - expect(result.currentStreak).toBe(14); |
411 | | - expect(result.longestStreak).toBe(14); |
412 | | - expect(result.totalContributions).toBe(14); |
| 394 | +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; |
| 395 | + |
| 396 | +it('todayDate matches YYYY-MM-DD for a normal calendar', () => { |
| 397 | + // A typical calendar with contributions — todayDate must always be a valid date string |
| 398 | + // regardless of the contribution data, so the SVG pulse animation targets the right tower. |
| 399 | + const calendar = buildCalendar([1, 0, 1, 1, 0, 1, 1]); |
| 400 | + const fixedNow = new Date('2024-01-07T12:00:00Z'); |
| 401 | + const result = calculateStreak(calendar, 'UTC', fixedNow); |
| 402 | + expect(result.todayDate).toMatch(DATE_REGEX); |
| 403 | +}); |
| 404 | + |
| 405 | +it('todayDate matches YYYY-MM-DD for an empty calendar', () => { |
| 406 | + // An empty calendar has no days to fall back on, so todayDate is derived |
| 407 | + // purely from the current date — it must still be a valid YYYY-MM-DD string. |
| 408 | + const emptyCalendar = buildCalendar([]); |
| 409 | + const fixedNow = new Date('2024-03-15T00:00:00Z'); |
| 410 | + const result = calculateStreak(emptyCalendar, 'UTC', fixedNow); |
| 411 | + expect(result.todayDate).toMatch(DATE_REGEX); |
| 412 | +}); |
| 413 | + |
| 414 | +it('todayDate matches YYYY-MM-DD when a non-UTC timezone shifts the local date', () => { |
| 415 | + // When the caller passes a timezone like Asia/Kolkata, the local date can differ |
| 416 | + // from UTC (e.g. UTC is still Jan 14 but IST is already Jan 15). |
| 417 | + // The format must remain YYYY-MM-DD regardless of which day the timezone lands on. |
| 418 | + const calendar = buildCalendar([1, 1, 1, 1, 1, 1, 1]); |
| 419 | + const fixedNow = new Date('2024-01-07T20:00:00Z'); // 01:30 Jan 8 in IST (UTC+5:30) |
| 420 | + const result = calculateStreak(calendar, 'Asia/Kolkata', fixedNow); |
| 421 | + expect(result.todayDate).toMatch(DATE_REGEX); |
| 422 | +}); |
| 423 | + |
| 424 | +describe('calculateStreak — year boundary transition (Dec 31 → Jan 1)', () => { |
| 425 | + // Streak math relies on the flattened day array being chronologically ordered, |
| 426 | + // so a run that crosses from December into January must be counted as a single |
| 427 | + // continuous streak. This guards against off-by-one bugs where the calendar |
| 428 | + // year rollover (e.g. 2024-12-31 → 2025-01-01) is mistakenly treated as a gap. |
| 429 | + it('counts a streak that spans the Dec 31 → Jan 1 boundary as one continuous run', () => { |
| 430 | + const calendar: ContributionCalendar = { |
| 431 | + totalContributions: 7, |
| 432 | + weeks: [ |
| 433 | + { |
| 434 | + contributionDays: [ |
| 435 | + { contributionCount: 0, date: '2024-12-26' }, // gap before the streak begins |
| 436 | + { contributionCount: 1, date: '2024-12-27' }, |
| 437 | + { contributionCount: 1, date: '2024-12-28' }, |
| 438 | + { contributionCount: 1, date: '2024-12-29' }, |
| 439 | + { contributionCount: 1, date: '2024-12-30' }, |
| 440 | + { contributionCount: 1, date: '2024-12-31' }, // last day of the year |
| 441 | + { contributionCount: 1, date: '2025-01-01' }, // first day of the new year |
| 442 | + ], |
| 443 | + }, |
| 444 | + { |
| 445 | + contributionDays: [ |
| 446 | + { contributionCount: 1, date: '2025-01-02' }, // "today" |
| 447 | + ], |
| 448 | + }, |
| 449 | + ], |
| 450 | + }; |
| 451 | + |
| 452 | + // Pin "now" to Jan 2 so the final day is treated as today and the streak is live. |
| 453 | + const now = new Date('2025-01-02T12:00:00Z'); |
| 454 | + const result = calculateStreak(calendar, 'UTC', now); |
| 455 | + |
| 456 | + // The 7-day run (Dec 27 → Jan 2) must not be split by the year rollover. |
| 457 | + expect(result.currentStreak).toBe(7); |
| 458 | + expect(result.longestStreak).toBe(7); |
| 459 | + expect(result.totalContributions).toBe(7); |
| 460 | + expect(result.todayDate).toBe('2025-01-02'); |
| 461 | + }); |
| 462 | +}); |
| 463 | + |
| 464 | +// ---------- EPIC ENHANCEMENT TESTS ---------- |
| 465 | + |
| 466 | +describe('aggregateCalendars', () => { |
| 467 | + it('handles calendars with different numbers of weeks', () => { |
| 468 | + const cal1 = { |
| 469 | + totalContributions: 15, |
| 470 | + weeks: [ |
| 471 | + { |
| 472 | + contributionDays: [{ date: '2024-01-01', contributionCount: 5 }], |
| 473 | + }, |
| 474 | + { |
| 475 | + contributionDays: [{ date: '2024-01-08', contributionCount: 10 }], |
| 476 | + }, |
| 477 | + ], |
| 478 | + }; |
| 479 | + |
| 480 | + const cal2 = { |
| 481 | + totalContributions: 3, |
| 482 | + weeks: [ |
| 483 | + { |
| 484 | + contributionDays: [{ date: '2024-01-01', contributionCount: 3 }], |
| 485 | + }, |
| 486 | + ], |
| 487 | + }; |
| 488 | + |
| 489 | + const result = aggregateCalendars([cal1, cal2]); |
| 490 | + |
| 491 | + expect(result.weeks).toHaveLength(2); |
| 492 | + expect(result.weeks[0].contributionDays[0].contributionCount).toBe(8); |
| 493 | + expect(result.weeks[1].contributionDays[0].contributionCount).toBe(10); |
| 494 | + }); |
| 495 | + |
| 496 | + it('returns an empty calendar if no calendars are provided', () => { |
| 497 | + const result = aggregateCalendars([]); |
| 498 | + expect(result.totalContributions).toBe(0); |
| 499 | + expect(result.weeks).toEqual([]); |
| 500 | + }); |
| 501 | + |
| 502 | + it('aggregates multiple calendars correctly for orgs', () => { |
| 503 | + const cal1 = buildCalendar([1, 0, 2]); // total: 3 |
| 504 | + const cal2 = buildCalendar([0, 3, 1]); // total: 4 |
| 505 | + |
| 506 | + const result = aggregateCalendars([cal1, cal2]); |
| 507 | + |
| 508 | + expect(result.totalContributions).toBe(7); |
| 509 | + expect(result.weeks[0].contributionDays[0].contributionCount).toBe(1); // 1 + 0 |
| 510 | + expect(result.weeks[0].contributionDays[1].contributionCount).toBe(3); // 0 + 3 |
| 511 | + expect(result.weeks[0].contributionDays[2].contributionCount).toBe(3); // 2 + 1 |
| 512 | + }); |
| 513 | +}); |
| 514 | + |
| 515 | +describe('calculateWrappedStats', () => { |
| 516 | + it('returns weekendRatio as 0 when all contributions occur on weekdays', () => { |
| 517 | + const calendar = { |
| 518 | + totalContributions: 25, |
| 519 | + weeks: [ |
| 520 | + { |
| 521 | + contributionDays: [ |
| 522 | + { date: '2024-01-01', contributionCount: 5 }, // Mon |
| 523 | + { date: '2024-01-02', contributionCount: 5 }, // Tue |
| 524 | + { date: '2024-01-03', contributionCount: 5 }, // Wed |
| 525 | + { date: '2024-01-04', contributionCount: 5 }, // Thu |
| 526 | + { date: '2024-01-05', contributionCount: 5 }, // Fri |
| 527 | + ], |
| 528 | + }, |
| 529 | + ], |
| 530 | + }; |
| 531 | + |
| 532 | + const result = calculateWrappedStats(calendar); |
| 533 | + |
| 534 | + expect(result.weekendRatio).toBe(0); |
| 535 | + }); |
| 536 | + |
| 537 | + it('calculates GitHub Wrapped stats accurately', () => { |
| 538 | + // 2024-01-01 was a Monday. Indices 5 (Sat) and 6 (Sun) are the weekend. |
| 539 | + const cal = buildCalendar([0, 0, 0, 0, 0, 5, 15]); |
| 540 | + |
| 541 | + const result = calculateWrappedStats(cal); |
| 542 | + |
| 543 | + expect(result.totalContributions).toBe(20); |
| 544 | + expect(result.highestDailyCount).toBe(15); |
| 545 | + expect(result.mostActiveDate).toBe('2024-01-07'); |
| 546 | + expect(result.busiestMonth).toBe('2024-01'); |
| 547 | + expect(result.weekendRatio).toBe(100); |
| 548 | + }); |
| 549 | + |
| 550 | + // ISSUE OBJECTIVE #1056: Verify empty calendar returns safe zero values |
| 551 | + it('verify empty calendar returns safe zero values', () => { |
| 552 | + // 1. Call calculateWrappedStats with empty data |
| 553 | + expect(() => calculateWrappedStats({ totalContributions: 0, weeks: [] })).not.toThrow(); |
| 554 | + |
| 555 | + // 2. Actually get the result to test its properties |
| 556 | + const result = calculateWrappedStats({ totalContributions: 0, weeks: [] }); |
| 557 | + |
| 558 | + // 3. Assert weekendRatio === 0 (and specifically not NaN) |
| 559 | + expect(result.weekendRatio).toBe(0); |
| 560 | + |
| 561 | + // 4. Assert highestDailyCount === 0 |
| 562 | + expect(result.highestDailyCount).toBe(0); |
| 563 | + }); |
| 564 | + |
| 565 | + // ISSUE OBJECTIVE: Verify weekendRatio is 100 when all commits are on weekends |
| 566 | + // ========================================================================= |
| 567 | + it('returns weekendRatio === 100 when all contributions are on weekends', () => { |
| 568 | + // Note: 2026-05-02 is a Saturday, 2026-05-03 is a Sunday, 2026-05-04 is a Monday |
| 569 | + const weekendCalendar = { |
| 570 | + totalContributions: 10, |
| 571 | + weeks: [ |
| 572 | + { |
| 573 | + contributionDays: [ |
| 574 | + { date: '2026-05-02', contributionCount: 5 }, // Saturday (Weekend) |
| 575 | + { date: '2026-05-03', contributionCount: 5 }, // Sunday (Weekend) |
| 576 | + { date: '2026-05-04', contributionCount: 0 }, // Monday (Weekday - 0 commits) |
| 577 | + ], |
| 578 | + }, |
| 579 | + ], |
| 580 | + } as Parameters<typeof calculateWrappedStats>[0]; // Safely infers the exact type the function expects! |
| 581 | + |
| 582 | + const result = calculateWrappedStats(weekendCalendar); |
| 583 | + |
| 584 | + // Assert the ratio is exactly 100% |
| 585 | + expect(result.weekendRatio).toBe(100); |
| 586 | + }); |
413 | 587 | }); |
0 commit comments