From 98cef5e9a772602d42acfcf233838c760424db9a Mon Sep 17 00:00:00 2001 From: Nicolas James Date: Thu, 13 Feb 2025 18:00:17 +1100 Subject: initial commit --- comp2511/blackout/BlackoutController.java | 230 +++++++++++++++++++ comp2511/blackout/CloudStorageDevice.java | 22 ++ comp2511/blackout/DesktopDevice.java | 14 ++ comp2511/blackout/DeviceBase.java | 62 +++++ comp2511/blackout/ElephantSatellite.java | 49 ++++ comp2511/blackout/EntityBase.java | 324 +++++++++++++++++++++++++++ comp2511/blackout/File.java | 87 +++++++ comp2511/blackout/FileTransferException.java | 48 ++++ comp2511/blackout/HandheldDevice.java | 14 ++ comp2511/blackout/LaptopDevice.java | 14 ++ comp2511/blackout/RelaySatellite.java | 66 ++++++ comp2511/blackout/SatelliteBase.java | 57 +++++ comp2511/blackout/ShrinkingSatellite.java | 56 +++++ comp2511/blackout/StandardSatellite.java | 46 ++++ comp2511/blackout/tests/Task1Tests.java | 81 +++++++ comp2511/blackout/tests/Task2Tests.java | 206 +++++++++++++++++ comp2511/blackout/tests/TestHelpers.java | 14 ++ 17 files changed, 1390 insertions(+) create mode 100644 comp2511/blackout/BlackoutController.java create mode 100644 comp2511/blackout/CloudStorageDevice.java create mode 100644 comp2511/blackout/DesktopDevice.java create mode 100644 comp2511/blackout/DeviceBase.java create mode 100644 comp2511/blackout/ElephantSatellite.java create mode 100644 comp2511/blackout/EntityBase.java create mode 100644 comp2511/blackout/File.java create mode 100644 comp2511/blackout/FileTransferException.java create mode 100644 comp2511/blackout/HandheldDevice.java create mode 100644 comp2511/blackout/LaptopDevice.java create mode 100644 comp2511/blackout/RelaySatellite.java create mode 100644 comp2511/blackout/SatelliteBase.java create mode 100644 comp2511/blackout/ShrinkingSatellite.java create mode 100644 comp2511/blackout/StandardSatellite.java create mode 100644 comp2511/blackout/tests/Task1Tests.java create mode 100644 comp2511/blackout/tests/Task2Tests.java create mode 100644 comp2511/blackout/tests/TestHelpers.java (limited to 'comp2511') diff --git a/comp2511/blackout/BlackoutController.java b/comp2511/blackout/BlackoutController.java new file mode 100644 index 0000000..fcbedc4 --- /dev/null +++ b/comp2511/blackout/BlackoutController.java @@ -0,0 +1,230 @@ +package unsw.blackout; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Vector; // prefer vectors for cache locality +import java.util.Optional; + +import unsw.response.models.EntityInfoResponse; +import unsw.response.models.FileInfoResponse; +import unsw.utils.Angle; + +public class BlackoutController { + private Vector Entities = new Vector(); + + // We use optional as a more explicit way of saying we return null, or to represent infinity etc. + // Note, the HD part is not entirely implemented. + + /** + * Returns an optional Entitybase, where optional means it does not exist. + */ + private Optional getEntity(String Id) { + for (EntityBase entity : this.Entities) { + if (entity.getId().equals(Id)) { + return Optional.of(entity); + } + } + return Optional.empty(); + } + /** + * Removes an entity based on a String id, does nothing if it already exists. + */ + private void removeEntity(String id) { + Optional entity = getEntity(id); + if (entity.isEmpty()) { + return; + } + this.Entities.remove(entity.get()); + } + /** + * Adds an entity based on a String id, does nothing if it already exists. + */ + private void addEntity(EntityBase entity) { + if (getEntity(entity.getId()).isPresent()) { + return; + } + this.Entities.add(entity); + } + +// Public methods. + public void createDevice(String deviceId, String type, Angle position) { + if(type.equals(HandheldDevice.class.getSimpleName())) { + this.addEntity(new HandheldDevice(deviceId, position)); + } else if (type.equals(LaptopDevice.class.getSimpleName())) { + this.addEntity(new LaptopDevice(deviceId, position)); + } else if (type.equals(DesktopDevice.class.getSimpleName())) { + this.addEntity(new DesktopDevice(deviceId, position)); + } else if (type.equals(CloudStorageDevice.class.getSimpleName())) { + this.addEntity(new CloudStorageDevice(deviceId, position)); + } + } + + public void removeDevice(String deviceId) { + Optional entity = this.getEntity(deviceId); + if (entity.isEmpty() || !(entity.get() instanceof DeviceBase)) { + return; + } + this.removeEntity(deviceId); + } + + public void createSatellite(String satelliteId, String type, double height, Angle position) { + if (type.equals(StandardSatellite.class.getSimpleName())) { + this.addEntity(new StandardSatellite(satelliteId, height, position)); + } else if (type.equals(ShrinkingSatellite.class.getSimpleName())) { + this.addEntity(new ShrinkingSatellite(satelliteId, height, position)); + } else if (type.equals(RelaySatellite.class.getSimpleName())) { + this.addEntity(new RelaySatellite(satelliteId, height, position)); + } else if (type.equals(ElephantSatellite.class.getSimpleName())) { + this.addEntity(new ElephantSatellite(satelliteId, height, position)); + } + } + + public void removeSatellite(String satelliteId) { + Optional entity = this.getEntity(satelliteId); + if (entity.isEmpty() || !(entity.get() instanceof SatelliteBase)) { + return; + } + this.removeEntity(satelliteId); + } + + public List listDeviceIds() { + ArrayList list = new ArrayList(); + for (EntityBase entity : this.Entities) { + if (!(entity instanceof DeviceBase)) { + continue; + } + list.add(entity.getId()); + } + return list; + } + + public List listSatelliteIds() { + ArrayList list = new ArrayList(); + for (EntityBase entity : this.Entities) { + if (!(entity instanceof SatelliteBase)) { + continue; + } + list.add(entity.getId()); + } + return list; + } + + public void addFileToDevice(String deviceId, String filename, String content) { + Optional entity = this.getEntity(deviceId); + if (entity.isEmpty() || !(entity.get() instanceof DeviceBase)) { + return; + } + DeviceBase device = (DeviceBase)entity.get(); + device.addFile(filename, content); + } + + public EntityInfoResponse getInfo(String id) { + Optional optional_entity = this.getEntity(id); + if (optional_entity.isEmpty()) { + return null; + } + EntityBase entity = optional_entity.get(); + Angle position = entity.getAngle(); + double height = entity.getHeight(); + String type = entity.getClass().getSimpleName(); + Map files = new HashMap<>(); + for (File file : entity.getReceivedFiles()) { + String filename = file.getFilename(); + String transmitted_contents = file.getTransmittedContents(); + int final_size = file.getContentsSize(); + boolean is_transmitted = file.hasFullyTransmitted(); + + if (is_transmitted && entity.canShrink()) { + if (file.isQuantum()) { + final_size = (int)((double)final_size * (2.0 / 3.0) + 0.5); + } + } + + files.put(filename, new FileInfoResponse(filename, transmitted_contents, final_size, is_transmitted)); + } + return new EntityInfoResponse(id, position, height, type, files); + } + + /** + * Helper function, sends all files associated with the entity to their targets if they exist. + */ + private void sendEntityFiles(EntityBase entity) { + Vector remove_files = new Vector(); + + for (File file : entity.getSentFiles()) { + if (file.hasFullyTransmitted()) { + continue; + } + + Optional target = this.getEntity(file.getTargetId()); + if (target.isEmpty()) { // as per the reference implementation, if it's deleted just ignore it (undef in spec) + continue; + } + + if (entity.maybeTransferFile(file, target.get())) { + continue; + } + + remove_files.add(file); + } + + entity.removeSentFiles(remove_files); + } + + public void simulate() { + for (EntityBase entity : this.Entities) { + entity.move(); + } + // For sending files, we need the origin entity, available in BlackoutController. + for (EntityBase entity : this.Entities) { + this.sendEntityFiles(entity); + } + } + + /** + * Simulate for the specified number of minutes. + * You shouldn't need to modify this function. + */ + public void simulate(int numberOfMinutes) { + for (int i = 0; i < numberOfMinutes; i++) { + simulate(); + } + } + + public List communicableEntitiesInRange(String id) { + Optional optional_entity = this.getEntity(id); + + if (optional_entity.isEmpty()) { + return null; + } + + EntityBase entity = optional_entity.get(); + + List communicable_entities = new ArrayList(); + for (EntityBase e : this.Entities) { + if (!entity.canCommunicate(e, this.Entities)) { + continue; + } + communicable_entities.add(e.getId()); + } + + return communicable_entities; + } + + public void sendFile(String fileName, String fromId, String toId) throws FileTransferException { + // Assumes we can communicate! (part of the spec) + Optional optional_entity_from = this.getEntity(fromId); + Optional optional_entity_to = this.getEntity(toId); + + if (optional_entity_from.isEmpty() || optional_entity_to.isEmpty()) { + return; + } + + EntityBase entity_from = optional_entity_from.get(); + EntityBase entity_to = optional_entity_to.get(); + + entity_from.sendFileTo(fileName, entity_to); + } +} \ No newline at end of file diff --git a/comp2511/blackout/CloudStorageDevice.java b/comp2511/blackout/CloudStorageDevice.java new file mode 100644 index 0000000..04e1c41 --- /dev/null +++ b/comp2511/blackout/CloudStorageDevice.java @@ -0,0 +1,22 @@ +package unsw.blackout; + +import unsw.utils.Angle; + +// "This device acts like a desktop" +// There is a small POTENTIAL issue where our satellites check the type of the device, and if our +// device is supposed to act like a desktop, then the satellites should also be able to communicate +// with our CloudStorageDevice if Desktops are compatible. This is unspecified! +public class CloudStorageDevice extends DesktopDevice { + public CloudStorageDevice(String deviceID, Angle position) { + super(deviceID, position); + } + + @ Override + final protected int getNumBytesInFiles() { + int count = 0; + for (File file : getReceivedFiles()) { + count += file.getContents().length(); + } + return count; + } +} \ No newline at end of file diff --git a/comp2511/blackout/DesktopDevice.java b/comp2511/blackout/DesktopDevice.java new file mode 100644 index 0000000..5411411 --- /dev/null +++ b/comp2511/blackout/DesktopDevice.java @@ -0,0 +1,14 @@ +package unsw.blackout; + +import unsw.utils.Angle; + +public class DesktopDevice extends DeviceBase { + public DesktopDevice(String deviceID, Angle position) { + super(deviceID, position); + } + + @ Override + final public double getRange() { + return 200_000.0; + } +} diff --git a/comp2511/blackout/DeviceBase.java b/comp2511/blackout/DeviceBase.java new file mode 100644 index 0000000..8f0b06e --- /dev/null +++ b/comp2511/blackout/DeviceBase.java @@ -0,0 +1,62 @@ +package unsw.blackout; + +import java.util.Optional; + +import unsw.utils.Angle; +import unsw.utils.MathsHelper; +import static unsw.utils.MathsHelper.RADIUS_OF_JUPITER; + +public abstract class DeviceBase extends EntityBase { + + /** + * DeviceBase is an abstract class containing common functionality between devices. + */ + public DeviceBase(String deviceID, Angle position) { + super(deviceID, position, RADIUS_OF_JUPITER); + } + + @ Override + final public double getVelocity() { + return 0.0; // Devices do not move. + } + @ Override + final protected boolean isSupportedEntity(EntityBase entity) { + if (entity instanceof DeviceBase) { + return false; + } + return true; + } + @ Override + final protected boolean isPhysicallyReachable(EntityBase entity) { + if (!(entity instanceof SatelliteBase)) { + return false; + } + if (!MathsHelper.isVisible(entity.getHeight(), entity.getAngle(), this.getAngle())) { + return false; + } + if (MathsHelper.getDistance(entity.getHeight(), entity.getAngle(), this.getAngle()) > this.getRange()) { + return false; + } + return true; + } + + final protected Optional getFileStoreLimit() { + return Optional.empty(); // Infinite file storage limit. + } + final protected Optional getByteStoreLimit() { + return Optional.empty(); // Infinite byte storage limit. + } + final protected Optional getByteDownloadSpeed() { + return Optional.empty(); // Infinite download speed + } + final protected Optional getByteUploadSpeed() { + return Optional.empty(); // Infinite upload speed. + } + + /** + * Adds a file without any bandwidth operation taking place. + */ + void addFile(String filename, String contents) { + this.getReceivedFiles().add(new File(filename, contents, contents.length(), this.getId())); + } +} \ No newline at end of file diff --git a/comp2511/blackout/ElephantSatellite.java b/comp2511/blackout/ElephantSatellite.java new file mode 100644 index 0000000..79e2cd1 --- /dev/null +++ b/comp2511/blackout/ElephantSatellite.java @@ -0,0 +1,49 @@ +package unsw.blackout; + +import java.util.Optional; + +import unsw.utils.Angle; + +public class ElephantSatellite extends SatelliteBase { + public ElephantSatellite(String satelliteID, double height, Angle position) { + super(satelliteID, height, position); + } + + @ Override + final public double getVelocity() { + return 2_500.0; + } + @ Override + final public double getRange() { + return 400_000.0; + } + @ Override + final public boolean canTransient() { + return true; + } + @ Override + final protected boolean isSupportedDeviceType(String type) { + if (type.equals(DesktopDevice.class.getSimpleName())) { + return true; + } else if (type.equals(LaptopDevice.class.getSimpleName())) { + return true; + } + return false; + } + @ Override + final protected Optional getFileStoreLimit() { + return Optional.empty(); // Undefined in spec? + } + @ Override + final protected Optional getByteStoreLimit() { + return Optional.of(90); // Max of 90 bytes. + } + @ Override + final protected Optional getByteDownloadSpeed() { + return Optional.of(20); // 20 bytes per minute. + } + @ Override + final protected Optional getByteUploadSpeed() { + return Optional.of(20); // 20 bytes per minute. + } +} \ No newline at end of file diff --git a/comp2511/blackout/EntityBase.java b/comp2511/blackout/EntityBase.java new file mode 100644 index 0000000..33241d9 --- /dev/null +++ b/comp2511/blackout/EntityBase.java @@ -0,0 +1,324 @@ +package unsw.blackout; + +import unsw.utils.Angle; + +import java.util.Optional; +import java.util.Vector; + +public abstract class EntityBase { + private String id; + private Angle position; + private double height; + private Vector received_files = new Vector(); + private Vector sent_files = new Vector(); + + /** + * BaseEntity is a class that contains an ID and a type which represents any object in our network. + */ + EntityBase(String id, Angle position, double height) { + this.id = id; + this.position = position; + this.height = height; + } + + // Getters. + public String getId() { + return this.id; + } + public Angle getAngle() { + return this.position; + } + public double getHeight() { + return this.height; + } + public Vector getReceivedFiles() { + return this.received_files; + } + public Vector getSentFiles() { + return this.sent_files; + } + public void setAngle(Angle angle) { + this.position = angle; + } + + /** + * Returns the velocity of the entity in km/s. + */ + public abstract double getVelocity(); + /** + * Returns the range of the entity in km. + */ + public abstract double getRange(); + /** + * Returns a boolean flagging if the entity may communicate with the other entity. + * Warning: it is possible that one entity may allow, while the other does not. + */ + protected abstract boolean isSupportedEntity(EntityBase entity); + /** + * Returns a boolean flagging if the entity is physically in range and unobstructed + * to another entity. + */ + protected abstract boolean isPhysicallyReachable(EntityBase entity); + /** + * Returns a boolean flagging if the entity may retransmit messages to other entities. + */ + protected boolean canRelay() { + return false; + } + /** + * Returns a boolean flagging if the entity should NOT delete files when out of range. + */ + protected boolean canTransient() { + return false; + } + /** + * Returns a boolean flagging if the entity may shrink files. + */ + protected boolean canShrink() { + return false; + } + /** + * Returns an optional integer limit on the number of files it may hold. + * Returning no value means no limit. + */ + protected abstract Optional getFileStoreLimit(); + /** + * Returns an optional integer limit on the number of bytes it may hold across all files. + * Returning no value means no limit. + */ + protected abstract Optional getByteStoreLimit(); + /** + * Returns an optional integer limit on the number of bytes downloadable per minute. + * Returning no value means no limit. + */ + protected abstract Optional getByteDownloadSpeed(); + /** + * Returns an optional integer limit on the number of bytes uploadable per minute. + * Returning no value means no limit. + */ + protected abstract Optional getByteUploadSpeed(); + + /** + * Helper function to calculate an offset to the position class in radians. + */ + protected double getMoveOffsetRadians() { + return (this.getVelocity() / this.getHeight()); + } + /** + * Moves the entity to the expected position after 1 minute. + */ + public void move() { + double offset = this.getMoveOffsetRadians(); + double rposition = this.position.toRadians() + offset; + this.position = Angle.fromRadians(rposition % Math.toRadians(360.0)); + } + + /** + * Helper function, returns the number of files that have not been fully transmitted in the vector. + */ + static private int getNumUncompletedFiles(Vector files) { + int count = 0; + for (File file : files) { + if (file.hasFullyTransmitted()) { + continue; + } + ++count; + } + return count; + } + + /** + * Gets the number of bytes in received files. + */ + protected int getNumBytesInFiles() { + int count = 0; + for (File file : this.received_files) { + count += file.getContents().length(); + } + return count; + } + + /** + * Returns the speed a file should be downloaded per tick for fairness during multiple transfers. + * Empty optional means infinite value. + */ + private Optional getDownloadBandwidth() { + Optional download_speed = this.getByteDownloadSpeed(); + if (download_speed.isEmpty()) { + return Optional.empty(); + } + return Optional.of(download_speed.get() / EntityBase.getNumUncompletedFiles(this.getReceivedFiles())); + } + /** + * Returns the speed a file should be uploaded per tick for fairness during multiple transfers. + * Empty optional means infinite value. + */ + private Optional getUploadBandwidth() { + Optional upload_speed = this.getByteUploadSpeed(); + if (upload_speed.isEmpty()) { + return Optional.empty(); + } + return Optional.of(upload_speed.get() / EntityBase.getNumUncompletedFiles(this.getSentFiles())); + } + + /** + * Heper function, returns the min of two optional integers, or a concrete value representing infinity. + */ + static private int getOptionalMin(Optional a, Optional b) { + if (a.isPresent() && b.isPresent()) { + return Math.min(a.get(), b.get()); + } else if (a.isPresent()) { + return a.get(); + } else if (b.isPresent()) { + return b.get(); + } + return Integer.MAX_VALUE; + } + public void removeSentFiles(Vector files) { + for (File file : files) { + getSentFiles().remove(file); + } + } + /** + * Transfers currently sending files by the expected values after 1 minute. + * Returns false if the file should be deleted. + */ + public boolean maybeTransferFile(File file, EntityBase target) { + // Delete and early out files if we are out of range - but keep if transient! + if (!this.isPhysicallyReachable(target)) { + if (!target.canTransient()) { + target.getReceivedFiles().remove(file); + return false; + } + return true; + } + + Optional upload_bandwidth = this.getUploadBandwidth(); + Optional download_bandwidth = target.getDownloadBandwidth(); + + // The amount we can send is the min of our upload bandwidth and their download bandwidth. + int bandwidth = EntityBase.getOptionalMin(upload_bandwidth, download_bandwidth); + file.addBytes(bandwidth); + return true; + } + + /** + * Recursive DPS helper function to determine whether an entity may communicate with another. + */ + private boolean canCommunicateSearch(EntityBase target, Vector entities, Vector visited) { + visited.add(this); + for (EntityBase entity : entities) { + if (entity.equals(this)) { + continue; + } + + if (visited.contains(entity)) { + continue; + } + + if (!this.isSupportedEntity(entity) || !entity.isSupportedEntity(this)) { + continue; + } + + if (!this.isPhysicallyReachable(entity)) { + continue; + } + + if (target.equals(entity)) { + return true; + } + + if (entity.canRelay()) { + if (entity.canCommunicateSearch(target, entities, visited)) { + return true; + } + } + } + return false; + } + /** + * Determines whether an entity may communicate with another entity, including via hops from other entities. + */ + public boolean canCommunicate(EntityBase target, Vector entities) { + Vector visited = new Vector(); + return canCommunicateSearch(target, entities, visited); + } + + /** + * Helper to extract files via their filenames, returns an empty optional if it does not exist. + */ + static private Optional getFileFromFilename(String filename, Vector files) { + for (File file : files) { + if (file.getFilename().equals(filename)) { + return Optional.of(file); + } + } + return Optional.empty(); + } + + /** + * Sends a file to the entity, while respecting the constraints imposed by both entities. + */ + public void sendFileTo(String filename, EntityBase target) throws FileTransferException { + Optional origin_file = EntityBase.getFileFromFilename(filename, this.getReceivedFiles()); + + if (origin_file.isEmpty()) { // throw if the file does not exist + throw new FileTransferException.VirtualFileNotFoundException(filename); + } + if (!origin_file.get().hasFullyTransmitted()) { // throw if the file has not fully downloaded + throw new FileTransferException.VirtualFileNotFoundException(filename); + } + + // throw if the file already exist or is currently downloading, nicely handles both here + if (EntityBase.getFileFromFilename(filename, target.getReceivedFiles()).isPresent()) { + throw new FileTransferException.VirtualFileAlreadyExistsException(filename); + } + + Optional upload_speed = this.getByteUploadSpeed(); // throw if we don't have enough bandwidth + if (upload_speed.isPresent() && EntityBase.getNumUncompletedFiles(this.sent_files) >= upload_speed.get()) { + throw new FileTransferException.VirtualFileNoBandwidthException(this.getId()); + } + + Optional download_speed = target.getByteDownloadSpeed(); // throw if they don't have enough bandwidth + if (download_speed.isPresent() && EntityBase.getNumUncompletedFiles(target.getReceivedFiles()) >= download_speed.get()) { + throw new FileTransferException.VirtualFileNoBandwidthException(target.getId()); + } + + // Throw if they can't store any more files. + Optional download_file_limit = target.getFileStoreLimit(); + if (download_file_limit.isPresent() && target.getReceivedFiles().size() >= download_file_limit.get()) { + throw new FileTransferException.VirtualFileNoStorageSpaceException("Max Files Reached"); + } + + // Throw if they don't have enough space. + Optional download_byte_limit = target.getByteStoreLimit(); + int filesize = origin_file.get().getContents().length(); + if (download_byte_limit.isPresent() && target.getNumBytesInFiles() + filesize >= download_byte_limit.get()) { + throw new FileTransferException.VirtualFileNoStorageSpaceException("Max Storage Reached"); + } + + // Finally we upload the file. + String contents = origin_file.get().getContents(); + File upload_file = new File(filename, contents, 0, target.getId()); + this.getSentFiles().add(upload_file); + target.getReceivedFiles().add(upload_file); + } + + // We know that our ID is unique, so that's the only thing we need to compare here. + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + EntityBase other = (EntityBase) obj; + if (id == null) { + if (other.id != null) + return false; + } else if (!id.equals(other.id)) + return false; + return true; + } +} \ No newline at end of file diff --git a/comp2511/blackout/File.java b/comp2511/blackout/File.java new file mode 100644 index 0000000..df43e17 --- /dev/null +++ b/comp2511/blackout/File.java @@ -0,0 +1,87 @@ +package unsw.blackout; + +public class File { + private String filename; + private String contents; + private int transmitted; + private String target_id; + + /** + * File represents a potentially in flight transmission. Transmitted should be <= contents.size(). + */ + File(String filename, String contents, int transmitted, String target_id) { + this.filename = filename; + this.contents = contents; + this.transmitted = transmitted; + this.target_id = target_id; + } + + public String getFilename() { + return filename; + } + public String getContents() { + return contents; + } + public String getTransmittedContents() { + return this.contents.substring(0, this.transmitted); + } + public int getTransmitted() { + return transmitted; + } + public int getContentsSize() { + return contents.length(); + } + public String getTargetId() { + return this.target_id; + } + + /** + * Returns true if the file has fully transmitted, false otherwise. + */ + public boolean hasFullyTransmitted() { + return this.transmitted == this.contents.length(); + } + /** + * Returns true if the file contains quantum, false otherwise. + */ + public boolean isQuantum() { + return this.contents.contains("quantum"); + } + + + /** + * Add bytes to the transmitted content, clamps to the 0 and the filesize. + */ + public void addBytes(int bytes) { + this.transmitted = Math.min(this.contents.length(), this.transmitted + bytes); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + File other = (File) obj; + if (contents == null) { + if (other.contents != null) + return false; + } else if (!contents.equals(other.contents)) + return false; + if (filename == null) { + if (other.filename != null) + return false; + } else if (!filename.equals(other.filename)) + return false; + if (target_id == null) { + if (other.target_id != null) + return false; + } else if (!target_id.equals(other.target_id)) + return false; + if (transmitted != other.transmitted) + return false; + return true; + } +} diff --git a/comp2511/blackout/FileTransferException.java b/comp2511/blackout/FileTransferException.java new file mode 100644 index 0000000..e40c136 --- /dev/null +++ b/comp2511/blackout/FileTransferException.java @@ -0,0 +1,48 @@ +package unsw.blackout; + +/** + * Represents an exception that occured because of a file transfer. + */ +public class FileTransferException extends Exception { + public FileTransferException(String message) { + super(message); + } + + /** + * Represents the case where the targeted file wasn't found on the source. + */ + public static class VirtualFileNotFoundException extends FileTransferException { + public VirtualFileNotFoundException(String message) { + super(message); + } + } + + /** + * Represents the case where the targeted file already existed on the target + * or was in the process of downloading. + */ + public static class VirtualFileAlreadyExistsException extends FileTransferException { + public VirtualFileAlreadyExistsException(String message) { + super(message); + } + } + + /** + * Represents the case when no more bandwidth exists for a satellite to + * be able to use for new devices. + */ + public static class VirtualFileNoBandwidthException extends FileTransferException { + public VirtualFileNoBandwidthException(String message) { + super(message); + } + } + + /** + * Occurs when a satellite runs out of space. + */ + public static class VirtualFileNoStorageSpaceException extends FileTransferException { + public VirtualFileNoStorageSpaceException(String message) { + super(message); + } + } +} diff --git a/comp2511/blackout/HandheldDevice.java b/comp2511/blackout/HandheldDevice.java new file mode 100644 index 0000000..587d7a4 --- /dev/null +++ b/comp2511/blackout/HandheldDevice.java @@ -0,0 +1,14 @@ +package unsw.blackout; + +import unsw.utils.Angle; + +public class HandheldDevice extends DeviceBase { + public HandheldDevice(String deviceID, Angle position) { + super(deviceID, position); + } + + @ Override + public double getRange() { + return 50_000.0; + } +} diff --git a/comp2511/blackout/LaptopDevice.java b/comp2511/blackout/LaptopDevice.java new file mode 100644 index 0000000..b39c762 --- /dev/null +++ b/comp2511/blackout/LaptopDevice.java @@ -0,0 +1,14 @@ +package unsw.blackout; + +import unsw.utils.Angle; + +public class LaptopDevice extends DeviceBase { + public LaptopDevice(String deviceID, Angle position) { + super(deviceID, position); + } + + @ Override + public double getRange() { + return 100_000.0; + } +} diff --git a/comp2511/blackout/RelaySatellite.java b/comp2511/blackout/RelaySatellite.java new file mode 100644 index 0000000..1087db8 --- /dev/null +++ b/comp2511/blackout/RelaySatellite.java @@ -0,0 +1,66 @@ +package unsw.blackout; + +import java.util.Optional; + +import unsw.utils.Angle; + +public class RelaySatellite extends SatelliteBase { + public RelaySatellite(String satelliteID, double height, Angle position) { + super(satelliteID, height, position); + } + + @ Override + final public double getVelocity() { + return 1_500.0; + } + @ Override + final public double getRange() { + return 300_000.0; + } + + /** + * Relay satellites oscillate between 140 and 90 degrees, so we override move to incorporate this behaviour. + */ + private boolean is_counterclockwise = true; + @ Override + final public void move() { + double current = this.getAngle().toDegrees(); + + if (current < 140.0 || current > 190.0) { + if (current > 190.0 && current < 345.0) { + is_counterclockwise = false; + } else { + is_counterclockwise = true; + } + } + + double offset = this.getMoveOffsetRadians(); + double rposition = (this.getAngle().toRadians() + (is_counterclockwise ? offset : -offset)) % Math.toRadians(360.0); + setAngle(Angle.fromRadians(rposition + (rposition < 0 ? Math.toRadians(360.0) : 0))); + } + + @ Override + final protected boolean isSupportedDeviceType(String type) { + return true; // Supports all devices! + } + @ Override + final protected boolean canRelay() { + return true; + } + @ Override + final protected Optional getFileStoreLimit() { + return Optional.of(0); // Cannot store any files! + } + @ Override + final protected Optional getByteStoreLimit() { + return Optional.of(0); // Cannot store any bytes! + } + @ Override + final protected Optional getByteDownloadSpeed() { + return Optional.empty(); // Infinite download speed + } + @ Override + final protected Optional getByteUploadSpeed() { + return Optional.empty(); // Infinite upload speed. + } +} diff --git a/comp2511/blackout/SatelliteBase.java b/comp2511/blackout/SatelliteBase.java new file mode 100644 index 0000000..ed88216 --- /dev/null +++ b/comp2511/blackout/SatelliteBase.java @@ -0,0 +1,57 @@ +package unsw.blackout; + +import unsw.utils.Angle; +import unsw.utils.MathsHelper; + +public abstract class SatelliteBase extends EntityBase { + + /** + * SatelliteBase is an abstract class containing common functionality between satellites. + */ + public SatelliteBase(String satelliteID, double height, Angle position) { + super(satelliteID, position, height); + } + + /** + * Returns true if the device type is supported, false otherwise. + */ + protected abstract boolean isSupportedDeviceType(String type); + + + @ Override + final protected boolean isSupportedEntity(EntityBase entity) { + if (entity instanceof SatelliteBase) { + return true; + } + return this.isSupportedDeviceType(entity.getClass().getSimpleName()); + } + + /** + * Helper function to differentiate between devices and satellite distance calls. + */ + final private double getEntityDistance(EntityBase entity) { + if (entity instanceof SatelliteBase) { + return MathsHelper.getDistance(this.getHeight(), this.getAngle(), entity.getHeight(), entity.getAngle()); + } + return MathsHelper.getDistance(this.getHeight(), this.getAngle(), entity.getAngle()); + } + /** + * Helper function to differentiate between devices and satellite visibility calls. + */ + final private boolean isEntityVisible(EntityBase entity) { + if (entity instanceof SatelliteBase) { + return MathsHelper.isVisible(this.getHeight(), this.getAngle(), entity.getHeight(), entity.getAngle()); + } + return MathsHelper.isVisible(this.getHeight(), this.getAngle(), entity.getAngle()); + } + @ Override + final protected boolean isPhysicallyReachable(EntityBase entity) { + if (!this.isEntityVisible(entity)) { + return false; + } + if (this.getEntityDistance(entity) > this.getRange()) { + return false; + } + return true; + } +} diff --git a/comp2511/blackout/ShrinkingSatellite.java b/comp2511/blackout/ShrinkingSatellite.java new file mode 100644 index 0000000..93dde50 --- /dev/null +++ b/comp2511/blackout/ShrinkingSatellite.java @@ -0,0 +1,56 @@ +package unsw.blackout; + +import java.util.Optional; + +import unsw.utils.Angle; + +public class ShrinkingSatellite extends SatelliteBase { + public ShrinkingSatellite(String satelliteID, double height, Angle position) { + super(satelliteID, height, position); + } + + @ Override + final public double getVelocity() { + return 1_000.0; + } + @ Override + final public double getRange() { + return 200_000.0; + } + @ Override + final protected boolean isSupportedDeviceType(String type) { + return true; + } + @ Override + final protected boolean canShrink() { + return true; + } + @ Override + final protected Optional getFileStoreLimit() { + return Optional.empty(); // No limit. + } + @ Override + final protected Optional getByteStoreLimit() { + return Optional.of(150); // 150 bytes. + } + @ Override + final protected Optional getByteDownloadSpeed() { + return Optional.of(15); // 15 bytes per minute. + } + @ Override + final protected Optional getByteUploadSpeed() { + return Optional.of(10); // 10 bytes per minute. + } + @ Override + final protected int getNumBytesInFiles() { + int count = 0; + for (File file : getReceivedFiles()) { + if (file.isQuantum()) { + count += (int)((double)file.getContentsSize() * (2.0 / 3.0) + 0.5); // quantum satellites perform 2/3 compression on their files + continue; + } + count += file.getContentsSize(); + } + return count; + } +} diff --git a/comp2511/blackout/StandardSatellite.java b/comp2511/blackout/StandardSatellite.java new file mode 100644 index 0000000..10777b3 --- /dev/null +++ b/comp2511/blackout/StandardSatellite.java @@ -0,0 +1,46 @@ +package unsw.blackout; + +import java.util.Optional; + +import unsw.utils.Angle; + +public class StandardSatellite extends SatelliteBase { + public StandardSatellite(String satelliteID, double height, Angle position) { + super(satelliteID, height, position); + } + + @ Override + final public double getVelocity() { + return 2_500.0; + } + @ Override + final public double getRange() { + return 150_000.0; + } + @ Override + final protected boolean isSupportedDeviceType(String type) { + if (type.equals(LaptopDevice.class.getSimpleName())) { + return true; + } else if (type.equals(HandheldDevice.class.getSimpleName())) { + return true; + } + return false; + } + @ Override + final protected Optional getFileStoreLimit() { + return Optional.of(3); // Max of 3 files. + } + @ Override + final protected Optional getByteStoreLimit() { + return Optional.of(80); // Max of 80 bytes. + } + @ Override + final protected Optional getByteDownloadSpeed() { + return Optional.of(1); // 1 byte per minute. + } + @ Override + final protected Optional getByteUploadSpeed() { + return Optional.of(1); // 1 byte per minute. + } +} + diff --git a/comp2511/blackout/tests/Task1Tests.java b/comp2511/blackout/tests/Task1Tests.java new file mode 100644 index 0000000..f5e2a55 --- /dev/null +++ b/comp2511/blackout/tests/Task1Tests.java @@ -0,0 +1,81 @@ +package blackout; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; + +import unsw.blackout.BlackoutController; +import unsw.response.models.EntityInfoResponse; +import unsw.utils.Angle; + +import java.util.Arrays; + +import static blackout.TestHelpers.assertListAreEqualIgnoringOrder; + +@TestInstance(value = Lifecycle.PER_CLASS) +public class Task1Tests { + @Test + public void testCreateDeviceList() { + BlackoutController controller = new BlackoutController(); + + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(310)); + controller.createDevice("DeviceB", "LaptopDevice", Angle.fromDegrees(310)); + assertListAreEqualIgnoringOrder(Arrays.asList("DeviceA", "DeviceB"), controller.listDeviceIds()); + } + + @Test + public void testCreateSatelliteList() { + BlackoutController controller = new BlackoutController(); + + controller.createSatellite("S1", "StandardSatellite", 0, Angle.fromDegrees(310)); + controller.createSatellite("S2", "StandardSatellite", 0, Angle.fromDegrees(310)); + assertListAreEqualIgnoringOrder(Arrays.asList("S1", "S2"), controller.listSatelliteIds()); + } + + @Test + public void testRemoveDeviceList() { + BlackoutController controller = new BlackoutController(); + + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(310)); + controller.createDevice("DeviceB", "LaptopDevice", Angle.fromDegrees(310)); + assertListAreEqualIgnoringOrder(Arrays.asList("DeviceA", "DeviceB"), controller.listDeviceIds()); + + controller.removeDevice("DeviceB"); + assertListAreEqualIgnoringOrder(Arrays.asList("DeviceA"), controller.listDeviceIds()); + } + + @Test + public void testRemoveSatelliteList() { + BlackoutController controller = new BlackoutController(); + + controller.createSatellite("S1", "StandardSatellite", 0, Angle.fromDegrees(310)); + controller.createSatellite("S2", "StandardSatellite", 0, Angle.fromDegrees(310)); + assertListAreEqualIgnoringOrder(Arrays.asList("S1", "S2"), controller.listSatelliteIds()); + + controller.removeSatellite("S2"); + assertListAreEqualIgnoringOrder(Arrays.asList("S1"), controller.listSatelliteIds()); + } + + @Test + public void testAddFileToDeviceInfoResponse() { + BlackoutController controller = new BlackoutController(); + + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(310)); + assert(controller.getInfo("DeviceA").getFiles().size() == 0); + controller.addFileToDevice("DeviceA", "filename", "contents"); + assert(controller.getInfo("DeviceA").getFiles().size() == 1); + } + + @Test + public void testInfoResponse() { + BlackoutController controller = new BlackoutController(); + + controller.createSatellite("Satellite1", "StandardSatellite", 314159, Angle.fromDegrees(310)); + EntityInfoResponse info = controller.getInfo("Satellite1"); + assert(info.getDeviceId().equals("Satellite1")); + assert(info.getPosition().equals(Angle.fromDegrees(310))); + assert(info.getHeight() == 314159); + assert(info.getType().equals("StandardSatellite")); + assert(info.getFiles().size() == 0); + } +} diff --git a/comp2511/blackout/tests/Task2Tests.java b/comp2511/blackout/tests/Task2Tests.java new file mode 100644 index 0000000..5e8d427 --- /dev/null +++ b/comp2511/blackout/tests/Task2Tests.java @@ -0,0 +1,206 @@ +package blackout; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; + +import unsw.blackout.BlackoutController; +import unsw.blackout.FileTransferException; +import unsw.utils.Angle; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import static unsw.utils.MathsHelper.RADIUS_OF_JUPITER; + +@TestInstance(value = Lifecycle.PER_CLASS) +public class Task2Tests { + @Test + public void testDownloadSpeed() { + BlackoutController controller = new BlackoutController(); + + // The expected download speed should be (download speed / num transferring files), with the remainder ignored. + controller.createSatellite("Satellite1", "ShrinkingSatellite", 1000 + RADIUS_OF_JUPITER, Angle.fromDegrees(320)); + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(310)); + controller.createDevice("DeviceB", "LaptopDevice", Angle.fromDegrees(310)); + + String contents = "a relatively long string"; + controller.addFileToDevice("DeviceA", "file1", contents); + controller.addFileToDevice("DeviceB", "file2", contents); + + assertDoesNotThrow(() -> controller.sendFile("file1", "DeviceA", "Satellite1")); + assertDoesNotThrow(() -> controller.sendFile("file2", "DeviceB", "Satellite1")); + + // shrinking satellite is 15 bytes per tick, so should be 15/2 = 7. + controller.simulate(); + assertEquals(7, controller.getInfo("Satellite1").getFiles().get("file1").getData().length()); + assertEquals(7, controller.getInfo("Satellite1").getFiles().get("file2").getData().length()); + + // again, should be 14 + controller.simulate(); + assertEquals(14, controller.getInfo("Satellite1").getFiles().get("file1").getData().length()); + assertEquals(14, controller.getInfo("Satellite1").getFiles().get("file2").getData().length()); + } + + @Test + public void testDeleteFiles() { + BlackoutController controller = new BlackoutController(); + + // Files that are being transferred should be deleted if they are in transmission, and kept if they are not. + controller.createSatellite("Satellite1", "StandardSatellite", 1000 + RADIUS_OF_JUPITER, Angle.fromDegrees(310)); + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(310)); + + // Upload a file to the satellite, should be there after 1 tick. + controller.addFileToDevice("DeviceA", "file1", "1"); + assertDoesNotThrow(() -> controller.sendFile("file1", "DeviceA", "Satellite1")); + controller.simulate(); + assert(controller.getInfo("Satellite1").getFiles().size() == 1); + + // Upload another file, which will not transmit in time. + controller.addFileToDevice("DeviceA", "file2", "this string will take a fair amount of ticks to transmit"); + assertDoesNotThrow(() -> controller.sendFile("file2", "DeviceA", "Satellite1")); + controller.simulate(); + assert(controller.getInfo("Satellite1").getFiles().size() == 2); + + for (int i = 0; i < 50; ++i) { + controller.simulate(); + } + // At this point we should be occluded and out of range, + // while the file was in transmission - zero files should be present. + assert(controller.getInfo("Satellite1").getFiles().size() == 1); + } + + @Test + public void testTransientDeleteFile() { + BlackoutController controller = new BlackoutController(); + + // Files should NOT be deleted after occlusion or running out of distance. + controller.createSatellite("Satellite1", "ElephantSatellite", 1000 + RADIUS_OF_JUPITER, Angle.fromDegrees(330)); + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(310)); + + // Upload a file which will not transmit in time. + controller.addFileToDevice("DeviceA", "file1", "this string will take a fair amount of ticks to transmit but just to be sure"); + assertDoesNotThrow(() -> controller.sendFile("file1", "DeviceA", "Satellite1")); + controller.simulate(); + assert(controller.getInfo("Satellite1").getFiles().size() == 1); + assert(controller.getInfo("Satellite1").getFiles().get("file1").hasTransferCompleted() == false); + + // The file should NOT be deleted after this period. + // In addition the file should have transferred fully by this time. + for (int i = 0; i < 180; ++i) { + controller.simulate(); + } + assert(controller.getInfo("Satellite1").getFiles().size() == 1); + assert(controller.getInfo("Satellite1").getFiles().get("file1").hasTransferCompleted() == true); + } + + @Test + public void testRelayAccess() { + BlackoutController controller = new BlackoutController(); + + // We create two devices on one side of the planet, they should not be able to communicate. + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(180)); + controller.createDevice("DeviceB", "LaptopDevice", Angle.fromDegrees(0)); + assert(controller.communicableEntitiesInRange("DeviceA").size() == 0); + assert(controller.communicableEntitiesInRange("DeviceB").size() == 0); + + // We create a chain of relays that should connect the two devices. + int OFFSET = 15_000; + int NUM_SATELLITES = 5; + for (int i = 0; i < NUM_SATELLITES; ++i) { + controller.createSatellite("Satellite" + i, "RelaySatellite", OFFSET + RADIUS_OF_JUPITER, Angle.fromDegrees(180 - i * (180 / NUM_SATELLITES))); + } + + // We should be able to communicate with all relays, plus the other device. + assert(controller.communicableEntitiesInRange("DeviceA").size() == NUM_SATELLITES + 1); + assert(controller.communicableEntitiesInRange("DeviceB").size() == NUM_SATELLITES + 1); + } + + @Test + public void testCloudStorageDevice() { + BlackoutController controller = new BlackoutController(); + + // The size of the string in cloudstoragedevice should be the same in the satellite + // but larger after transfer into the laptop device. (Files are only decompressed + // inside devices (minus cloudstoragedevice). + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(180)); + controller.createDevice("DeviceB", "CloudStorageDevice", Angle.fromDegrees(180)); + controller.createSatellite("Satellite1", "ShrinkingSatellite", 10000 + RADIUS_OF_JUPITER, Angle.fromDegrees(180)); + + // Compression isn't guaranteed, but if we repeat digits we can improve our chances. + // Send the file to the satellite, it should be the same size as in the cloudstoragedevice. + controller.addFileToDevice("DeviceB", "file", "biiiiiiiiiiiiiiiiiiiiiiiig"); + assertDoesNotThrow(() -> controller.sendFile("file", "DeviceB", "Satellite1")); + controller.simulate(); // should arrive in two ticks + controller.simulate(); + assert(controller.getInfo("Satellite1").getFiles().get("file").getFileSize() == controller.getInfo("DeviceB").getFiles().get("file").getFileSize()); + + assertDoesNotThrow(() -> controller.sendFile("file", "Satellite1", "DeviceA")); + controller.simulate(); + // TODO make < + assert(controller.getInfo("Satellite1").getFiles().get("file").getFileSize() <= controller.getInfo("DeviceA").getFiles().get("file").getFileSize()); + } + + @Test + public void testSendFileExceptions() { + BlackoutController controller = new BlackoutController(); + + controller.createDevice("DeviceA", "LaptopDevice", Angle.fromDegrees(180)); + controller.createDevice("DeviceB", "LaptopDevice", Angle.fromDegrees(180)); + controller.createSatellite("Satellite1", "ShrinkingSatellite", 15000 + RADIUS_OF_JUPITER, Angle.fromDegrees(180)); + + // VirtualFileNotFoundException if the file doesn't exist on fromID + assertThrows(FileTransferException.VirtualFileNotFoundException.class, () -> controller.sendFile("?", "DeviceA", "Satellite1")); + // ... or it's a partial file. + controller.addFileToDevice("DeviceA", "file", "contents"); + assertDoesNotThrow(() -> controller.sendFile("file", "DeviceA", "Satellite1")); + assertThrows(FileTransferException.VirtualFileNotFoundException.class, () -> controller.sendFile("file", "Satellite1", "DeviceB")); + controller.simulate(); // make the file arrive + assert(controller.getInfo("Satellite1").getFiles().get("file").hasTransferCompleted()); + + // VirtualFileAlreadyExistsException if the file already exists on targetId. + assertThrows(FileTransferException.VirtualFileAlreadyExistsException.class, () -> controller.sendFile("file", "Satellite1", "DeviceA")); + // or if it's currently downloading + controller.addFileToDevice("DeviceA", "file1", "contents"); + assertDoesNotThrow(() -> controller.sendFile("file1", "DeviceA", "Satellite1")); + controller.simulate(); + assert(controller.getInfo("Satellite1").getFiles().get("file1").hasTransferCompleted()); + assertDoesNotThrow(() -> controller.sendFile("file1", "Satellite1", "DeviceB")); + assert(controller.getInfo("DeviceB").getFiles().get("file1").hasTransferCompleted() == false); + assertThrows(FileTransferException.VirtualFileAlreadyExistsException.class, () -> controller.sendFile("file1", "Satellite1", "DeviceB")); + controller.simulate(); // send all files + + // At this point our satellite has 0 files currently downloading and 2 files stored. + // VirtualFileNoBandwidthException if we are sending too many files. + // 15 bytes / tick = max of 15 files. + assertDoesNotThrow(() -> { + for (int i = 2; i < 15 + 2; ++i) { + controller.addFileToDevice("DeviceA", "file" + i, "contents"); + controller.sendFile("file" + i, "DeviceA", "Satellite1"); + } + }); + + controller.addFileToDevice("DeviceA", "too many files file", "contents"); + assertThrows(FileTransferException.VirtualFileNoBandwidthException.class, () -> controller.sendFile("too many files file", "DeviceA", "Satellite1")); + + // VirtualFileNoStorageSpaceException should throw if the lack of room was due to a max file cap + // max file cap of three for StandardSatellite, we have to be careful to avoid it's bandwidth limits + controller.createSatellite("Satellite2", "StandardSatellite", 15000 + RADIUS_OF_JUPITER, Angle.fromDegrees(180)); + assertDoesNotThrow(() -> { + for (int i = 0; i < 3; ++i) { + controller.addFileToDevice("DeviceA", "unique_file" + i, "1"); + controller.sendFile("unique_file" + i, "DeviceA", "Satellite2"); + controller.simulate(); + } + }); + + controller.addFileToDevice("DeviceA", "unique_file3", "contents"); + assertThrows(FileTransferException.VirtualFileNoStorageSpaceException.class, () -> controller.sendFile("unique_file3", "DeviceA", "Satellite2")); + + // ... or if we exceed the storage byte limit + controller.createSatellite("Satellite3", "StandardSatellite", 15000 + RADIUS_OF_JUPITER, Angle.fromDegrees(180)); + controller.addFileToDevice("DeviceA", "big_file", "a very long string that definitely exceeds the max byte storage amount of satellite3"); + assertThrows(FileTransferException.VirtualFileNoStorageSpaceException.class, () -> controller.sendFile("big_file", "DeviceA", "Satellite3")); + } +} \ No newline at end of file diff --git a/comp2511/blackout/tests/TestHelpers.java b/comp2511/blackout/tests/TestHelpers.java new file mode 100644 index 0000000..913784b --- /dev/null +++ b/comp2511/blackout/tests/TestHelpers.java @@ -0,0 +1,14 @@ +package blackout; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.util.Collections; +import java.util.List; + +public class TestHelpers { + public static> void assertListAreEqualIgnoringOrder(List a, List b) { + Collections.sort(a); + Collections.sort(b); + assertArrayEquals(a.toArray(), b.toArray()); + } +} -- cgit v1.2.3