<?php

declare(strict_types=1);

namespace Drupal\Tests\farm_group\Kernel;

use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\farm_test\Kernel\FarmAssetTestTrait;
use Drupal\Tests\farm_test\Kernel\FarmEntityCacheTestTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\asset\Entity\Asset;
use Drupal\farm_geo\Traits\WktTrait;
use Drupal\log\Entity\Log;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests for farmOS group membership logic.
 */
#[Group('farm')]
#[RunTestsInSeparateProcesses]
class GroupTest extends KernelTestBase {

  use StringTranslationTrait;
  use FarmAssetTestTrait;
  use FarmEntityCacheTestTrait;
  use UserCreationTrait;
  use WktTrait;

  /**
   * WKT Generator service.
   *
   * @var \Drupal\geofield\WktGeneratorInterface
   */
  protected $wktGenerator;

  /**
   * Group membership service.
   *
   * @var \Drupal\farm_group\GroupMembershipInterface
   */
  protected $groupMembership;

  /**
   * Asset location service.
   *
   * @var \Drupal\farm_location\AssetLocationInterface
   */
  protected $assetLocation;

  /**
   * Log location service.
   *
   * @var \Drupal\farm_location\LogLocationInterface
   */
  protected $logLocation;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'asset',
    'log',
    'entity',
    'farm_entity_access',
    'farm_field',
    'farm_group',
    'farm_group_test',
    'farm_location',
    'farm_log',
    'farm_log_asset',
    'farm_map',
    'geofield',
    'state_machine',
    'system',
    'user',
    'views',
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->wktGenerator = \Drupal::service('geofield.wkt_generator');
    $this->groupMembership = \Drupal::service('group.membership');
    $this->assetLocation = \Drupal::service('asset.location');
    $this->logLocation = \Drupal::service('log.location');
    $this->installEntitySchema('asset');
    $this->installEntitySchema('log');
    $this->installEntitySchema('user');
    $this->installConfig([
      'farm_group',
      'farm_group_test',
      'farm_log_asset',
    ]);
  }

  /**
   * Test asset group membership.
   */
  public function testGroupMembership() {

    // Create an animal asset.
    /** @var \Drupal\asset\Entity\AssetInterface $animal */
    $animal = Asset::create([
      'type' => 'animal',
      'name' => $this->randomMachineName(),
    ]);
    $animal->save();

    // Populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create group assets.
    /** @var \Drupal\asset\Entity\AssetInterface $first_group */
    $first_group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $first_group->save();
    /** @var \Drupal\asset\Entity\AssetInterface $second_group */
    $second_group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $second_group->save();

    // When an asset has no group assignment logs, it has no group membership.
    $this->assertFalse($this->groupMembership->hasGroup($animal), 'New assets do not have group membership.');
    $this->assertEmpty($this->groupMembership->getGroup($animal), 'New assets do not reference any groups.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group]), TRUE, 'New groups have no members.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'New groups have no members.');

    // Assert that the animal's cache tags were not invalidated.
    $this->assertEntityTestCache($animal, TRUE);

    // Create a "done" log that assigns the animal to the group.
    /** @var \Drupal\log\Entity\LogInterface $first_log */
    $first_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $first_group->id()],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $first_log->save();

    // When an asset has a done group assignment logs, it has group membership.
    $this->assertTrue($this->groupMembership->hasGroup($animal), 'Asset with group assignment has group membership.');
    $this->assertEquals($first_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Asset with group assignment is in the assigned group.');
    $this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$first_group]), TRUE, 'When an asset becomes a group member, the group has one member.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'When an asset becomes a group member, other groups are unaffected.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create a "pending" log that assigns the animal to the second group.
    /** @var \Drupal\log\Entity\LogInterface $second_log */
    $second_log = Log::create([
      'type' => 'test',
      'status' => 'pending',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $second_group->id()],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $second_log->save();

    // When an asset has a pending group assignment logs, it still has the same
    // group membership as before.
    $this->assertEquals($first_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Pending group assignment logs do not affect membership.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Groups with only pending membership have zero members.');

    // Assert that the animal's cache tags were not invalidated.
    $this->assertEntityTestCache($animal, TRUE);

    // Create an "abandoned" log that assigns the animal to the second group
    // and confirm that it still has the same group membership as before.
    /** @var \Drupal\log\Entity\LogInterface $abandoned_log */
    $abandoned_log = Log::create([
      'type' => 'test',
      'status' => 'abandoned',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $second_group->id()],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $abandoned_log->save();
    $this->assertEquals($first_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Abandoned group assignment logs do not affect membership.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Groups with only abandoned membership have zero members.');

    // Assert that the animal's cache tags were not invalidated.
    $this->assertEntityTestCache($animal, TRUE);

    // Change the pending log's status to "abandoned" and confirm that the group
    // membership is still unchanged.
    $second_log->set('status', 'abandoned');
    $second_log->save();
    $this->assertEquals($first_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Abandoned group assignment logs do not affect membership.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Groups with only abandoned membership logs have zero members.');

    // Assert that the animal's cache tags were not invalidated.
    $this->assertEntityTestCache($animal, TRUE);

    // When the log is marked as "done", the asset's membership is updated.
    $second_log->set('status', 'done');
    $second_log->save();
    $this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'A second group assignment log updates group membership.');
    $this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Completed group assignment logs add group members.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // When a log is changed from "done" to "abandoned", the asset's membership
    // is reverted to what it was previously.
    $second_log->set('status', 'abandoned');
    $second_log->save();
    $this->assertEquals($first_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Abandoned group assignment logs do not affect membership.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Groups with only abandoned membership logs have zero members.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // When a log is changed from "abandoned" to "done", the asset's membership
    // is updated.
    $second_log->set('status', 'done');
    $second_log->save();
    $this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'A second group assignment log updates group membership.');
    $this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Completed group assignment logs add group members.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create a third "done" log in the future.
    /** @var \Drupal\log\Entity\LogInterface $third_log */
    $third_log = Log::create([
      'type' => 'test',
      'timestamp' => \Drupal::time()->getRequestTime() + 86400,
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $first_group->id()],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $third_log->save();

    // When an asset has a "done" group assignment log in the future, the asset
    // group membership remains the same as the previous "done" movement log.
    $this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'A third group assignment log in the future does not update group membership.');
    $this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Future group assignment logs do not affect members.');

    // Assert that the animal's cache tags were not invalidated.
    $this->assertEntityTestCache($animal, TRUE);

    // Create a fourth log with no group reference.
    /** @var \Drupal\log\Entity\LogInterface $fourth_log */
    $fourth_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => [],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $fourth_log->save();

    // When a group assignment log is created with no group references, it
    // effectively "unsets" the asset's group membership.
    $this->assertFalse($this->groupMembership->hasGroup($animal), 'Asset group membership can be unset.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroup($animal), TRUE, 'Unset group membership does not reference any groups.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group]), TRUE, 'Unset group membership unsets group members.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Unset group membership unsets group members.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Delete the fourth log.
    $fourth_log->delete();

    // When a group membership is deleted the last group membership log is used.
    $this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'Deleting a group membership log updates group membership.');
    $this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'Deleting a group membership log updates group members.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Create a second animal.
    /** @var \Drupal\asset\Entity\AssetInterface $second_animal */
    $second_animal = Asset::create([
      'type' => 'animal',
      'name' => $this->randomMachineName(),
    ]);
    $second_animal->save();

    // Create a "done" log that assigns the second animal to the first group.
    /** @var \Drupal\log\Entity\LogInterface $fifth_log */
    $fifth_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $first_group->id()],
      'asset' => ['target_id' => $second_animal->id()],
    ]);
    $fifth_log->save();

    // Assert that group members from multiple groups can be queried together.
    $this->assertEquals($second_group->id(), $this->groupMembership->getGroup($animal)[0]->id(), 'The first animal is in the second group.');
    $this->assertEquals($first_group->id(), $this->groupMembership->getGroup($second_animal)[0]->id(), 'The second animal is in the first group.');
    $group_members = $this->groupMembership->getGroupMembers([$first_group, $second_group]);
    $this->assertCorrectAssets([$animal, $second_animal], $group_members, TRUE, 'Group members from multiple groups can be queried together.');
  }

  /**
   * Test past/future group membership.
   */
  public function testGroupMembershipTimestamp() {

    // Create an asset.
    /** @var \Drupal\asset\Entity\AssetInterface $asset */
    $asset = Asset::create([
      'type' => 'animal',
      'name' => $this->randomMachineName(),
    ]);
    $asset->save();

    // Create two group assets.
    /** @var \Drupal\asset\Entity\AssetInterface[] $groups */
    $groups = [];
    $group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $group->save();
    $groups[] = $group;
    $group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $group->save();
    $groups[] = $group;

    // Create a series of timestamps, each 24 hours apart.
    $now = \Drupal::time()->getRequestTime();
    $timestamps = [];
    for ($i = 0; $i < 3; $i++) {
      $timestamps[$i] = $now + (86400 * $i);
    }

    // Create a series of logs that assign the asset to each of the groups, and
    // a third that removes the asset from all groups.
    /** @var \Drupal\log\Entity\LogInterface[] $logs */
    $logs = [];
    $group_assignments = [];
    for ($i = 0; $i < 2; $i++) {
      $group_assignments[] = ['target_id' => $groups[$i]->id()];
    }
    $group_assignments[] = [];
    for ($i = 0; $i < 3; $i++) {
      $log = Log::create([
        'type' => 'test',
        'timestamp' => $timestamps[$i],
        'status' => 'done',
        'asset' => ['target_id' => $asset->id()],
        'is_group_assignment' => TRUE,
        'group' => $group_assignments[$i],
      ]);
      $log->save();
      $logs[] = $log;
    }

    // Confirm that the asset has no group membership before all logs.
    $timestamp = $now - 86400;
    $this->assertEquals(FALSE, $this->groupMembership->hasGroup($asset, $timestamp));
    $this->assertEquals([], $this->groupMembership->getGroup($asset, $timestamp));
    $this->assertNull($this->groupMembership->getGroupAssignmentLog($asset, $timestamp));
    $this->assertEquals([], $this->groupMembership->getGroupMembers($groups, TRUE, $timestamp));

    // Confirm that the asset is where it should be after each log.
    for ($i = 0; $i < 2; $i++) {
      $this->assertEquals(TRUE, $this->groupMembership->hasGroup($asset, $timestamps[$i]));
      $this->assertEquals($groups[$i]->id(), $this->groupMembership->getGroup($asset, $timestamps[$i])[0]->id());
      $this->assertEquals($logs[$i]->id(), $this->groupMembership->getGroupAssignmentLog($asset, $timestamps[$i])->id());
      $this->assertCorrectAssets([$asset], $this->groupMembership->getGroupMembers([$groups[$i]], TRUE, $timestamps[$i]), TRUE);
    }

    // Confirm that the asset is still in the same group 1 second later.
    // This tests the <= operator.
    $this->assertEquals(TRUE, $this->groupMembership->hasGroup($asset, $timestamps[1] + 1));
    $this->assertEquals($groups[1]->id(), $this->groupMembership->getGroup($asset, $timestamps[1] + 1)[0]->id());
    $this->assertEquals($logs[1]->id(), $this->groupMembership->getGroupAssignmentLog($asset, $timestamps[1] + 1)->id());
    $this->assertCorrectAssets([$asset], $this->groupMembership->getGroupMembers([$groups[1]], TRUE, $timestamps[1] + 1), TRUE);

    // Confirm that the asset has no group membership after the last log.
    $this->assertEquals(FALSE, $this->groupMembership->hasGroup($asset, $timestamps[2]));
    $this->assertEquals([], $this->groupMembership->getGroup($asset, $timestamps[2]));
    $this->assertEquals($logs[2]->id(), $this->groupMembership->getGroupAssignmentLog($asset, $timestamps[2])->id());
    $this->assertEquals([], $this->groupMembership->getGroupMembers($groups, TRUE, $timestamps[2]));
  }

  /**
   * Test recursive asset group membership.
   */
  public function testRecursiveGroupMembership() {

    // Create an animal asset.
    /** @var \Drupal\asset\Entity\AssetInterface $animal */
    $animal = Asset::create([
      'type' => 'animal',
      'name' => $this->randomMachineName(),
    ]);
    $animal->save();

    // Create group assets.
    /** @var \Drupal\asset\Entity\AssetInterface $first_group */
    $first_group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $first_group->save();
    /** @var \Drupal\asset\Entity\AssetInterface $second_group */
    $second_group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $second_group->save();

    // Create a "done" log to assign the animal to the second group.
    /** @var \Drupal\log\Entity\LogInterface $first_log */
    $first_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $second_group->id()],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $first_log->save();

    // Create a "pending" log to assign the second group to the first group.
    /** @var \Drupal\log\Entity\LogInterface $second_log */
    $second_log = Log::create([
      'type' => 'test',
      'status' => 'pending',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $first_group->id()],
      'asset' => ['target_id' => $second_group->id()],
    ]);
    $second_log->save();

    // Assert that the second group has no group and a single member.
    $this->assertFalse($this->groupMembership->hasGroup($second_group), 'The second group does not have a group.');
    $this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'The second group has one member.');

    // Assert that the first group has no members.
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group], FALSE), TRUE, 'The first group has no members.');
    $this->assertCorrectAssets([], $this->groupMembership->getGroupMembers([$first_group], TRUE), TRUE, 'The first group has no recursive members.');

    // Save the second log to create the nested group membership.
    $second_log->set('status', 'done');
    $second_log->save();

    // Assert that the second group has a group and a single member.
    $this->assertTrue($this->groupMembership->hasGroup($second_group), 'The second group has a group.');
    $this->assertCorrectAssets([$animal], $this->groupMembership->getGroupMembers([$second_group]), TRUE, 'The second group has one member.');

    // Assert that the first group has a single direct member.
    $first_group_members = $this->groupMembership->getGroupMembers([$first_group], FALSE);
    $this->assertCorrectAssets([$second_group], $first_group_members, TRUE, 'The first group has one direct member.');

    // Assert that the first group has two recursive members.
    $first_group_recursive_members = $this->groupMembership->getGroupMembers([$first_group], TRUE);
    $this->assertCorrectAssets([$animal, $second_group], $first_group_recursive_members, TRUE, 'The first group has two recursive members.');
  }

  /**
   * Test that circular group membership is prevented.
   */
  public function testCircularGroupMembership() {

    // Create and login a user with access to view any asset. This is necessary
    // to validate the log entities below, because the entity reference fields
    // use Views to validate that entities are referencable, and the Views use
    // the farm_entity_collection Views access plugin.
    $user = $this->createUser(['view any asset']);
    $this->container->get('current_user')->setAccount($user);

    // Create two group assets.
    /** @var \Drupal\asset\Entity\AssetInterface $first_group */
    $first_group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $first_group->save();
    /** @var \Drupal\asset\Entity\AssetInterface $second_group */
    $second_group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $second_group->save();

    // First, make sure we can't create a group assignment log that references
    // the same asset in both the asset and group fields.
    /** @var \Drupal\log\Entity\LogInterface $first_log */
    $first_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $first_group->id()],
      'asset' => ['target_id' => $first_group->id()],
    ]);

    // Validate the log and confirm that a circular group constraint
    // violation was set.
    $violations = $first_log->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals($this->t('%asset cannot be a member of itself.', ['%asset' => $first_group->label()]), $violations[0]->getMessage());

    // Tweak the log so that it moves the first group to the second group.
    $first_log->set('group', ['target_id' => $second_group->id()]);

    // Confirm that validation passes.
    $violations = $first_log->validate();
    $this->assertCount(0, $violations);

    // Save the first log.
    $first_log->save();

    // Start a log that assigns the second group to the first group (which
    // would cause a circular group membership).
    /** @var \Drupal\log\Entity\LogInterface $second_log */
    $second_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $first_group->id()],
      'asset' => ['target_id' => $second_group->id()],
    ]);

    // Validate the second log and confirm that a circular group constraint
    // violation was set.
    $violations = $second_log->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals($this->t('%asset cannot be a member of itself.', ['%asset' => $second_group->label()]), $violations[0]->getMessage());

    // Create a third group asset, so we can test recursive circular group
    // membership constraint.
    /** @var \Drupal\asset\Entity\AssetInterface $third_group */
    $third_group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $third_group->save();

    // Update the second log to reference the third group instead.
    $second_log->set('group', ['target_id' => $third_group->id()]);

    // Confirm that validation passes.
    $violations = $second_log->validate();
    $this->assertCount(0, $violations);

    // Save the second log.
    $second_log->save();

    // Now, start a third log that assigns the third group to the first group
    // (which would create a recursive circular group membership).
    /** @var \Drupal\log\Entity\LogInterface $third_log */
    $third_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $first_group->id()],
      'asset' => ['target_id' => $third_group->id()],
    ]);

    // Validate the third log and confirm that a circular group constraint
    // violation was set.
    $violations = $third_log->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals($this->t('%asset cannot be a member of itself.', ['%asset' => $third_group->label()]), $violations[0]->getMessage());
  }

  /**
   * Test asset location with group membership.
   */
  public function testAssetLocation() {

    // Create an animal asset.
    /** @var \Drupal\asset\Entity\AssetInterface $animal */
    $animal = Asset::create([
      'type' => 'animal',
      'name' => $this->randomMachineName(),
    ]);
    $animal->save();

    // Populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create a group asset.
    /** @var \Drupal\asset\Entity\AssetInterface $group */
    $group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $group->save();

    // Create a log that assigns the animal to the group.
    /** @var \Drupal\log\Entity\LogInterface $first_log */
    $first_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $group->id()],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $first_log->save();

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create two pasture assets.
    /** @var \Drupal\asset\Entity\AssetInterface $first_pasture */
    $first_pasture = Asset::create([
      'type' => 'pasture',
      'name' => $this->randomMachineName(),
    ]);
    $first_pasture->save();
    /** @var \Drupal\asset\Entity\AssetInterface $second_pasture */
    $second_pasture = Asset::create([
      'type' => 'pasture',
      'name' => $this->randomMachineName(),
    ]);
    $second_pasture->save();

    // Confirm that new locations are empty.
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'New locations are empty.');
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE);

    // Create a log that moves the animal to the first pasture.
    /** @var \Drupal\log\Entity\LogInterface $second_log */
    $second_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => ['target_id' => $first_pasture->id()],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $second_log->save();

    // Confirm that the animal is located in the first pasture.
    $this->assertEquals($this->logLocation->getLocation($second_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by asset membership log.');
    $this->assertEquals($this->logLocation->getGeometry($second_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by asset membership log.');
    $this->assertCorrectAssets([$animal], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'Locations have assets that are moved to them.');
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'Locations that do not have assets moved to them are unaffected.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create a log that moves the group to the second pasture.
    /** @var \Drupal\log\Entity\LogInterface $third_log */
    $third_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => ['target_id' => $second_pasture->id()],
      'asset' => ['target_id' => $group->id()],
    ]);
    $third_log->save();

    // Confirm that the animal is located in the second pasture.
    $this->assertEquals($this->logLocation->getLocation($third_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by group membership log.');
    $this->assertEquals($this->logLocation->getGeometry($third_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by group membership log.');
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'A group movement removes assets from their previous location.');
    $this->assertCorrectAssets([$animal, $group], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'A group movement adds assets to their new location.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create a log that unsets the group location.
    /** @var \Drupal\log\Entity\LogInterface $fourth_log */
    $fourth_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => [],
      'asset' => ['target_id' => $group->id()],
    ]);
    $fourth_log->save();

    // Confirm that the animal location was unset.
    $this->assertEquals($this->logLocation->getLocation($fourth_log), $this->assetLocation->getLocation($animal), 'Asset location can be unset by group membership log.');
    $this->assertEquals($this->logLocation->getGeometry($fourth_log), $this->assetLocation->getGeometry($animal), 'Asset geometry can be unset by group membership log.');
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'Unsetting group location removes member assets from all locations.');
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'Unsetting group location removes member assets from all locations.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Create a log that unsets the animal's group membership.
    /** @var \Drupal\log\Entity\LogInterface $fifth_log */
    $fifth_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => [],
      'asset' => ['target_id' => $animal->id()],
    ]);
    $fifth_log->save();

    // Confirm that the animal's location is determined by its own movement
    // logs now.
    $this->assertEquals($this->logLocation->getLocation($second_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by asset membership log.');
    $this->assertEquals($this->logLocation->getGeometry($second_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by asset membership log.');
    $this->assertCorrectAssets([$animal], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'Unsetting group membership adds assets to their previous location.');
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'Unsetting group membership removes member assets from the group location.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);

    // Delete the fifth log before re-populating asset cache.
    $fifth_log->delete();

    // Re-populate a cache value dependent on the animal's cache tags.
    $this->populateEntityTestCache($animal);

    // Delete the fourth log.
    // When a group's location log is deleted the group's last location is used.
    $fourth_log->delete();

    // Confirm that the animal is located in the second pasture.
    $this->assertEquals($this->logLocation->getLocation($third_log), $this->assetLocation->getLocation($animal), 'Asset location is determined by group membership log.');
    $this->assertEquals($this->logLocation->getGeometry($third_log), $this->assetLocation->getGeometry($animal), 'Asset geometry is determined by group membership log.');
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$first_pasture]), TRUE, 'A group movement removes assets from their previous location.');
    $this->assertCorrectAssets([$animal, $group], $this->assetLocation->getAssetsByLocation([$second_pasture]), TRUE, 'A group movement adds assets to their new location.');

    // Assert that the animal's cache tags were invalidated.
    $this->assertEntityTestCache($animal, FALSE);
  }

  /**
   * Test past/future asset location.
   */
  public function testAssetLocationTimestamp() {

    // Create an animal asset.
    /** @var \Drupal\asset\Entity\AssetInterface $asset */
    $asset = Asset::create([
      'type' => 'animal',
      'name' => $this->randomMachineName(),
    ]);
    $asset->save();

    // Create two pasture location assets.
    /** @var \Drupal\asset\Entity\AssetInterface[] $locations */
    $locations = [];
    $location = Asset::create([
      'type' => 'pasture',
      'name' => $this->randomMachineName(),
      'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
      'is_fixed' => TRUE,
      'is_location' => TRUE,
    ]);
    $location->save();
    $locations[] = $location;
    $location = Asset::create([
      'type' => 'pasture',
      'name' => $this->randomMachineName(),
      'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
      'is_fixed' => TRUE,
      'is_location' => TRUE,
    ]);
    $location->save();
    $locations[] = $location;

    // Create a group asset.
    /** @var \Drupal\asset\Entity\AssetInterface $group */
    $group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $group->save();

    // Create a series of timestamps, each 24 hours apart.
    $now = \Drupal::time()->getRequestTime();
    $timestamps = [];
    for ($i = 0; $i < 5; $i++) {
      $timestamps[$i] = $now + (86400 * $i);
    }

    // Create a series of movement and group assignment logs.
    $logs = [];

    // Move asset to location 1.
    $log = Log::create([
      'type' => 'test',
      'timestamp' => $timestamps[0],
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => ['target_id' => $locations[0]->id()],
      'asset' => ['target_id' => $asset->id()],
    ]);
    $log->save();
    $logs[] = $log;

    // Assign asset to group.
    $log = Log::create([
      'type' => 'test',
      'timestamp' => $timestamps[1],
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $group->id()],
      'asset' => ['target_id' => $asset->id()],
    ]);
    $log->save();
    $logs[] = $log;

    // Move group to location 2.
    $log = Log::create([
      'type' => 'test',
      'timestamp' => $timestamps[2],
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => ['target_id' => $locations[1]->id()],
      'asset' => ['target_id' => $group->id()],
    ]);
    $log->save();
    $logs[] = $log;

    // Move group to location 1.
    $log = Log::create([
      'type' => 'test',
      'timestamp' => $timestamps[3],
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => ['target_id' => $locations[0]->id()],
      'asset' => ['target_id' => $group->id()],
    ]);
    $log->save();
    $logs[] = $log;

    // Remove asset from group.
    $log = Log::create([
      'type' => 'test',
      'timestamp' => $timestamps[4],
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => [],
      'asset' => ['target_id' => $asset->id()],
    ]);
    $log->save();
    $logs[] = $log;

    // Confirm that the asset has no location before all logs.
    $timestamp = $now - 86400;
    $this->assertEquals(FALSE, $this->assetLocation->hasLocation($asset, $timestamp));
    $this->assertEquals(FALSE, $this->assetLocation->hasGeometry($asset, $timestamp));
    $this->assertEquals([], $this->assetLocation->getLocation($asset, $timestamp));
    $this->assertEquals('', $this->assetLocation->getGeometry($asset, $timestamp));
    $this->assertNull($this->assetLocation->getMovementLog($asset, $timestamp));
    $this->assertEquals([], $this->assetLocation->getAssetsByLocation($locations, $timestamp));

    // Confirm that the asset is in location 1 by itself after the first log.
    $this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[0]));
    $this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[0]));
    $this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[0]));
    $this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[0]));
    $this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[0])->id());
    $this->assertCorrectAssets([$asset], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[0]), TRUE);

    // Confirm that the asset is in location 1 by itself after the second log.
    $this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[1]));
    $this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[1]));
    $this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[1]));
    $this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[1]));
    $this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[1])->id());
    $this->assertCorrectAssets([$asset], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[1]), TRUE);

    // Confirm that the asset and group are in location 2 after the third log.
    $this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[2]));
    $this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[2]));
    $this->assertEquals($this->logLocation->getLocation($logs[2]), $this->assetLocation->getLocation($asset, $timestamps[2]));
    $this->assertEquals($this->logLocation->getGeometry($logs[2]), $this->assetLocation->getGeometry($asset, $timestamps[2]));
    $this->assertEquals($logs[2]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[2])->id());
    $this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[1]], $timestamps[2]), TRUE);

    // Confirm that the asset and group are in location 1 after the fourth log.
    $this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[3]));
    $this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[3]));
    $this->assertEquals($this->logLocation->getLocation($logs[3]), $this->assetLocation->getLocation($asset, $timestamps[3]));
    $this->assertEquals($this->logLocation->getGeometry($logs[3]), $this->assetLocation->getGeometry($asset, $timestamps[3]));
    $this->assertEquals($logs[3]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[3])->id());
    $this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[3]), TRUE);

    // Confirm that the asset has its first location after the last log.
    $this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[4]));
    $this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[4]));
    $this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[4]));
    $this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[4]));
    $this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[4])->id());
    $this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[4]), TRUE);

    // Confirm that the asset is still in the same location 1 second later.
    // This tests the <= operator.
    $this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset, $timestamps[4] + 1));
    $this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset, $timestamps[4] + 1));
    $this->assertEquals($this->logLocation->getLocation($logs[0]), $this->assetLocation->getLocation($asset, $timestamps[4] + 1));
    $this->assertEquals($this->logLocation->getGeometry($logs[0]), $this->assetLocation->getGeometry($asset, $timestamps[4] + 1));
    $this->assertEquals($logs[0]->id(), $this->assetLocation->getMovementLog($asset, $timestamps[4] + 1)->id());
    $this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]], $timestamps[4] + 1), TRUE);
  }

  /**
   * Regression test for issue #886.
   *
   * Test that creating an asset movement log in the past does not override the
   * latest group movement log.
   *
   * This is a regression test for: Creating old movement logs disrupts current
   * location logic #886
   *
   * @see https://github.com/farmOS/farmOS/issues/886
   */
  public function testPastAssetMovement() {

    // Create an animal asset.
    /** @var \Drupal\asset\Entity\AssetInterface $asset */
    $asset = Asset::create([
      'type' => 'animal',
      'name' => $this->randomMachineName(),
    ]);
    $asset->save();

    // Create two pasture location assets.
    /** @var \Drupal\asset\Entity\AssetInterface[] $locations */
    $locations = [];
    $location = Asset::create([
      'type' => 'pasture',
      'name' => $this->randomMachineName(),
      'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
      'is_fixed' => TRUE,
      'is_location' => TRUE,
    ]);
    $location->save();
    $locations[] = $location;
    $location = Asset::create([
      'type' => 'pasture',
      'name' => $this->randomMachineName(),
      'intrinsic_geometry' => $this->reduceWkt($this->wktGenerator->wktGeneratePolygon(NULL, rand(3, 7))),
      'is_fixed' => TRUE,
      'is_location' => TRUE,
    ]);
    $location->save();
    $locations[] = $location;

    // Create a group asset.
    /** @var \Drupal\asset\Entity\AssetInterface $group */
    $group = Asset::create([
      'type' => 'group',
      'name' => $this->randomMachineName(),
    ]);
    $group->save();

    // Assign asset to group.
    $group_assignment_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_group_assignment' => TRUE,
      'group' => ['target_id' => $group->id()],
      'asset' => ['target_id' => $asset->id()],
    ]);
    $group_assignment_log->save();

    // Move group to location 1.
    $group_movement_log = Log::create([
      'type' => 'test',
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => ['target_id' => $locations[0]->id()],
      'asset' => ['target_id' => $group->id()],
    ]);
    $group_movement_log->save();

    // Confirm that the asset is located where the group is.
    $this->assertEquals(TRUE, $this->assetLocation->hasLocation($asset));
    $this->assertEquals(TRUE, $this->assetLocation->hasGeometry($asset));
    $this->assertEquals($this->logLocation->getLocation($group_movement_log), $this->assetLocation->getLocation($asset));
    $this->assertEquals($this->logLocation->getGeometry($group_movement_log), $this->assetLocation->getGeometry($asset));
    $this->assertEquals($group_movement_log->id(), $this->assetLocation->getMovementLog($asset)->id());
    $this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]]), TRUE);

    // Move asset to location 2 with a timestamp of one year ago.
    $asset_movement_log = Log::create([
      'timestamp' => strtotime('1 year ago'),
      'type' => 'test',
      'status' => 'done',
      'is_movement' => TRUE,
      'location' => ['target_id' => $locations[1]->id()],
      'asset' => ['target_id' => $asset->id()],
    ]);
    $asset_movement_log->save();

    // Confirm that the asset is still located where the group is.
    $this->assertEquals($this->assetLocation->getLocation($group), $this->assetLocation->getLocation($asset));
    $this->assertEquals($this->assetLocation->getGeometry($group), $this->assetLocation->getGeometry($asset));
    $this->assertEquals($group_movement_log->id(), $this->assetLocation->getMovementLog($asset)->id());
    $this->assertCorrectAssets([$asset, $group], $this->assetLocation->getAssetsByLocation([$locations[0]]), TRUE);
    $this->assertCorrectAssets([], $this->assetLocation->getAssetsByLocation([$locations[1]]), TRUE);
  }

}
