Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/Progressable.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Verseles\Progressable;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Verseles\Progressable\Exceptions\UniqueNameAlreadySetException;
use Verseles\Progressable\Exceptions\UniqueNameNotSetException;
Expand All @@ -27,6 +28,11 @@ trait Progressable {
*/
protected ?int $currentStep = null;

/**
* The timestamp when the progress started.
*/
protected ?int $startTime = null;

/**
* The callback function for saving cache data.
*
Expand Down Expand Up @@ -219,6 +225,8 @@ public function getLocalProgress(?int $precision = null): float {
* @throws UniqueNameNotSetException
*/
public function resetLocalProgress(): static {
$this->startTime = Carbon::now()->timestamp;

return $this->setLocalProgress(0);
}

Expand Down Expand Up @@ -247,6 +255,36 @@ public function isOverallComplete(): bool {
return $this->getOverallProgress(0) >= 100;
}

/**
* Get the estimated time remaining in seconds.
*/
public function getEstimatedTimeRemaining(): ?int {
if ($this->progress >= 100) {
return 0;
}

if ($this->startTime === null) {
// Try to load from storage
$progressData = $this->getOverallProgressData();
$this->startTime = $progressData[$this->getLocalKey()]['start_time'] ?? null;
}

if ($this->startTime === null || $this->progress <= 0) {
return null;
}

$elapsed = Carbon::now()->timestamp - $this->startTime;

if ($elapsed <= 0) {
return null;
}

$rate = $this->progress / $elapsed; // progress per second
$remainingProgress = 100 - $this->progress;

return (int) round($remainingProgress / $rate);
}

/**
* Remove this instance from the overall progress calculation.
*
Expand Down Expand Up @@ -303,10 +341,20 @@ public function setLocalProgress(float $progress): static {
protected function updateLocalProgressData(float $progress): static {
$progressData = $this->getOverallProgressData();

// Recover start_time if null and not resetting (progress >= 0)
if ($this->startTime === null && $progress >= 0) {
$currentData = $progressData[$this->getLocalKey()] ?? [];
$this->startTime = $currentData['start_time'] ?? Carbon::now()->timestamp;
}

$localData = [
'progress' => $progress,
];

if ($this->startTime !== null) {
$localData['start_time'] = $this->startTime;
}

if ($this->statusMessage !== null) {
$localData['message'] = $this->statusMessage;
}
Expand Down
140 changes: 140 additions & 0 deletions tests/EtaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

namespace Verseles\Progressable\Tests;

use Illuminate\Support\Carbon;
use Orchestra\Testbench\TestCase;
use Verseles\Progressable\Progressable;

class EtaTest extends TestCase {
use Progressable;

private string $testId;

protected function setUp(): void {
parent::setUp();
$this->testId = uniqid('test_', true);

// Reset properties
$this->progress = 0;
// $this->startTime = null; // Will be available after trait update
unset($this->overallUniqueName);
}

public function test_eta_is_null_initially(): void {
$this->setOverallUniqueName('test_eta_init_'.$this->testId);
// Method doesn't exist yet, so this test will fail until implemented
if (method_exists($this, 'getEstimatedTimeRemaining')) {
$this->assertNull($this->getEstimatedTimeRemaining());
} else {
$this->markTestSkipped('getEstimatedTimeRemaining not implemented yet');
}
}

public function test_eta_calculation(): void {
if (! method_exists($this, 'getEstimatedTimeRemaining')) {
$this->markTestSkipped('getEstimatedTimeRemaining not implemented yet');
}

Carbon::setTestNow(Carbon::now());

$this->setOverallUniqueName('test_eta_calc_'.$this->testId);

// Start progress
$this->setLocalProgress(0);

// Advance time by 10 seconds
Carbon::setTestNow(Carbon::now()->addSeconds(10));

// Set progress to 10%
// Rate = 10% / 10s = 1% per second
// Remaining = 90%
// ETA = 90s
$this->setLocalProgress(10);

$this->assertEquals(90, $this->getEstimatedTimeRemaining());

// Advance time by another 10 seconds (total 20s)
Carbon::setTestNow(Carbon::now()->addSeconds(10));

// Set progress to 50%
// Rate = 50% / 20s = 2.5% per second
// Remaining = 50%
// ETA = 50 / 2.5 = 20s
$this->setLocalProgress(50);

$this->assertEquals(20, $this->getEstimatedTimeRemaining());
}

public function test_eta_is_zero_when_complete(): void {
if (! method_exists($this, 'getEstimatedTimeRemaining')) {
$this->markTestSkipped('getEstimatedTimeRemaining not implemented yet');
}

$this->setOverallUniqueName('test_eta_complete_'.$this->testId);
$this->setLocalProgress(100);
$this->assertEquals(0, $this->getEstimatedTimeRemaining());
}

public function test_reset_clears_start_time(): void {
if (! method_exists($this, 'getEstimatedTimeRemaining')) {
$this->markTestSkipped('getEstimatedTimeRemaining not implemented yet');
}

Carbon::setTestNow(Carbon::now());

$this->setOverallUniqueName('test_eta_reset_'.$this->testId);
$this->setLocalProgress(10);

// Ensure start time was set (indirectly via ETA calculation)
Carbon::setTestNow(Carbon::now()->addSeconds(10));
$this->assertNotNull($this->getEstimatedTimeRemaining());

$this->resetLocalProgress();

// After reset, ETA should be null (because progress is 0 and start time should be cleared)
$this->assertNull($this->getEstimatedTimeRemaining());

// Start again. Current time is T+10s.
// We set progress to 0 (reset).
// Advance 10s to T+20s.
Carbon::setTestNow(Carbon::now()->addSeconds(10));

// Set progress to 20%.
// Elapsed since NEW start (T+10s) is 10s.
// Rate = 20% / 10s = 2% per second.
// Remaining 80%. ETA = 40s.

$this->setLocalProgress(20);
$this->assertEquals(40, $this->getEstimatedTimeRemaining());
}

public function test_start_time_persistence(): void {
if (! method_exists($this, 'getEstimatedTimeRemaining')) {
$this->markTestSkipped('getEstimatedTimeRemaining not implemented yet');
}

Carbon::setTestNow(Carbon::now());
$uniqueName = 'test_eta_persistence_'.$this->testId;

$this->setOverallUniqueName($uniqueName);
$this->setLocalProgress(10); // Start time set at T0

// Create new instance simulating another process or request
$obj2 = new class {
use Progressable;
};
$obj2->setOverallUniqueName($uniqueName);

// Advance time
Carbon::setTestNow(Carbon::now()->addSeconds(10));

// Obj2 updates progress to 20%
// It should pick up start time from T0
// Elapsed = 10s. Progress = 20%. Rate = 2% / s.
// Remaining = 80%. ETA = 40s.
$obj2->setLocalProgress(20);

$this->assertEquals(40, $obj2->getEstimatedTimeRemaining());
}
}