diff --git a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilder.java b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilder.java index 8fdcb5f..22b2ffb 100644 --- a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilder.java +++ b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilder.java @@ -19,7 +19,6 @@ import ch.sbb.pfi.netzgrafikeditor.converter.util.time.ServiceDayTime; import java.time.Duration; -import java.time.LocalDate; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -29,29 +28,15 @@ public class GtfsSupplyBuilder extends BaseSupplyBuilder { - public static final int ROUTE_TYPE = 0; - private static final Agency AGENCY = Agency.builder() - .agencyId("nge") - .agencyName("Netzgrafik Editor") - .agencyTimeZone("UTC") - .agencyUrl("https://github.com/SchweizerischeBundesbahnen/netzgrafik-editor-frontend") - .build(); - private static final Calendar CALENDAR = Calendar.builder() - .serviceId("always") - .monday(Calendar.Type.AVAILABLE) - .tuesday(Calendar.Type.AVAILABLE) - .wednesday(Calendar.Type.AVAILABLE) - .thursday(Calendar.Type.AVAILABLE) - .friday(Calendar.Type.AVAILABLE) - .startDate(LocalDate.MAX) - .endDate(LocalDate.MAX) - .build(); + public static final int ROUTE_TYPE = 2; // rail + private final List stops = new ArrayList<>(); private final List routes = new ArrayList<>(); private final List trips = new ArrayList<>(); private final List stopTimes = new ArrayList<>(); private final Set createdRoutes = new HashSet<>(); + private final Map tripCounts = new HashMap<>(); private final Map> routeElements = new HashMap<>(); public GtfsSupplyBuilder(InfrastructureRepository infrastructureRepository, VehicleCircuitsPlanner vehicleCircuitsPlanner) { @@ -74,36 +59,47 @@ protected void buildTransitRoute(TransitRouteContainer transitRouteContainer) { // store route elements for stop time creation routeElements.put(transitRouteContainer.transitRouteInfo().getId(), transitRouteContainer.routeElements()); + // build transit route names + String routeShortName = String.format("%s - %s", + transitRouteContainer.routeElements().getFirst().getStopFacilityInfo().getId(), + transitRouteContainer.routeElements().getLast().getStopFacilityInfo().getId()); + String routeLongName = String.format("%s: %s", + transitRouteContainer.transitRouteInfo().getTransitLineInfo().getCategory(), routeShortName); + // create and add GTFS route (transit line in the context of the supply builder) if not yet added String routeId = transitRouteContainer.transitRouteInfo().getTransitLineInfo().getId(); if (!createdRoutes.contains(routeId)) { routes.add(Route.builder() .routeId(routeId) - .agencyId(AGENCY.getAgencyId()) - .routeLongName(routeId) - .routeShortName(routeId) + .agencyId(Agency.DEFAULT_ID) + .routeLongName(routeLongName) + .routeShortName(routeShortName) .routeType(ROUTE_TYPE) .build()); createdRoutes.add(routeId); } - - // create and add trip - trips.add(Trip.builder() - .routeId(routeId) - .serviceId(CALENDAR.getServiceId()) - .tripId(transitRouteContainer.transitRouteInfo().getId()) - .tripHeadsign(transitRouteContainer.routeElements().getLast().getStopFacilityInfo().getId()) - .build()); } @Override protected void buildDeparture(VehicleAllocation vehicleAllocation) { - String tripId = vehicleAllocation.getDepartureInfo().getTransitRouteInfo().getId(); + String routeId = vehicleAllocation.getDepartureInfo().getTransitRouteInfo().getId(); + String tripId = String.format("%s_%d", routeId, tripCounts.merge(routeId, 1, Integer::sum)); + List currentRouteElements = routeElements.get(routeId); + + // create and add trip + trips.add(Trip.builder() + .routeId(vehicleAllocation.getDepartureInfo().getTransitRouteInfo().getTransitLineInfo().getId()) + .serviceId(Calendar.DEFAULT_ID) + .tripId(tripId) + .tripHeadsign(currentRouteElements.getLast().getStopFacilityInfo().getId()) + .build()); + + // create and add stop times: gtfs stop time sequence starts with 1 not 0 + final int[] count = {1}; final ServiceDayTime[] time = {vehicleAllocation.getDepartureInfo().getTime()}; - final int[] count = {0}; - for (RouteElement routeElement : routeElements.get(tripId)) { + for (RouteElement routeElement : currentRouteElements) { routeElement.accept(new RouteElementVisitor() { @Override @@ -112,7 +108,7 @@ public void visit(RouteStop routeStop) { Duration dwellTime = routeStop.getDwellTime(); // set time to arrival time if at start of stop time sequence - if (count[0] == 0) { + if (count[0] == 1) { time[0] = time[0].minus(dwellTime); } @@ -142,13 +138,6 @@ public void visit(RoutePass routePass) { @Override protected GtfsSchedule getResult() { - return GtfsSchedule.builder() - .agencies(List.of(AGENCY)) - .stops(stops) - .routes(routes) - .trips(trips) - .stopTimes(stopTimes) - .calendars(List.of(CALENDAR)) - .build(); + return GtfsSchedule.builder().stops(stops).routes(routes).trips(trips).stopTimes(stopTimes).build(); } } diff --git a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Agency.java b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Agency.java index 5003e6a..0ed6f1e 100644 --- a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Agency.java +++ b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Agency.java @@ -7,12 +7,18 @@ @Builder public class Agency { - String agencyId; + public static final String DEFAULT_ID = "nge"; - String agencyName; + @Builder.Default + String agencyId = DEFAULT_ID; - String agencyUrl; + @Builder.Default + String agencyName = "Netzgrafik Editor"; - String agencyTimeZone; + @Builder.Default + String agencyUrl = "https://github.com/SchweizerischeBundesbahnen/netzgrafik-editor-frontend"; + + @Builder.Default + String agencyTimezone = "UTC"; } diff --git a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Calendar.java b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Calendar.java index 24d76b7..3939214 100644 --- a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Calendar.java +++ b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/Calendar.java @@ -5,29 +5,44 @@ import java.time.LocalDate; +import static ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.FeedInfo.DEFAULT_END_DATE; +import static ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.FeedInfo.DEFAULT_START_DATE; + @Value @Builder public class Calendar { - String serviceId; + public static final String DEFAULT_ID = "always"; + + @Builder.Default + String serviceId = DEFAULT_ID; - Type monday; + @Builder.Default + Type monday = Type.AVAILABLE; - Type tuesday; + @Builder.Default + Type tuesday = Type.AVAILABLE; - Type wednesday; + @Builder.Default + Type wednesday = Type.AVAILABLE; - Type thursday; + @Builder.Default + Type thursday = Type.AVAILABLE; - Type friday; + @Builder.Default + Type friday = Type.AVAILABLE; - Type saturday; + @Builder.Default + Type saturday = Type.AVAILABLE; - Type sunday; + @Builder.Default + Type sunday = Type.AVAILABLE; - LocalDate startDate; + @Builder.Default + LocalDate startDate = DEFAULT_START_DATE; - LocalDate endDate; + @Builder.Default + LocalDate endDate = DEFAULT_END_DATE; public enum Type { AVAILABLE, diff --git a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/FeedInfo.java b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/FeedInfo.java new file mode 100644 index 0000000..896ab41 --- /dev/null +++ b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/FeedInfo.java @@ -0,0 +1,34 @@ +package ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model; + +import lombok.Builder; +import lombok.Value; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Value +@Builder +public class FeedInfo { + + public static final LocalDate DEFAULT_START_DATE = LocalDate.of(1970, 1, 1); + public static final LocalDate DEFAULT_END_DATE = LocalDate.of(2099, 12, 31); + + @Builder.Default + String feedPublisherName = "Netzgrafik Editor Converter"; + + @Builder.Default + String feedPublisherUrl = "https://github.com/SchweizerischeBundesbahnen/netzgrafik-editor-converter"; + + @Builder.Default + String feedLang = "en"; + + @Builder.Default + LocalDate feedStartDate = DEFAULT_START_DATE; + + @Builder.Default + LocalDate feedEndDate = DEFAULT_END_DATE; + + @Builder.Default + LocalDateTime feedVersion = LocalDateTime.now(); + +} diff --git a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/GtfsSchedule.java b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/GtfsSchedule.java index 2447a30..a84677d 100644 --- a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/GtfsSchedule.java +++ b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/model/GtfsSchedule.java @@ -9,7 +9,11 @@ @Builder public class GtfsSchedule { - List agencies; + @Builder.Default + FeedInfo feedInfo = FeedInfo.builder().build(); + + @Builder.Default + List agencies = List.of(Agency.builder().build()); List stops; @@ -19,6 +23,7 @@ public class GtfsSchedule { List stopTimes; - List calendars; + @Builder.Default + List calendars = List.of(Calendar.builder().build()); } diff --git a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/app/ConversionService.java b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/app/ConversionService.java index 982dd0f..b287d66 100644 --- a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/app/ConversionService.java +++ b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/app/ConversionService.java @@ -30,7 +30,7 @@ public void convert(Path networkGraphicFile, Path outputDirectory, NetworkGraphi case GTFS -> { SupplyBuilder builder = new GtfsSupplyBuilder(new NoInfrastructureRepository(), new NoVehicleCircuitsPlanner(new NoRollingStockRepository())); - ConverterSink sink = new GtfsScheduleWriter(outputDirectory); + ConverterSink sink = new GtfsScheduleWriter(outputDirectory, true); yield new NetworkGraphicConverter<>(config, source, builder, sink); } diff --git a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/io/gtfs/GtfsScheduleWriter.java b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/io/gtfs/GtfsScheduleWriter.java index a656893..ed75eee 100644 --- a/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/io/gtfs/GtfsScheduleWriter.java +++ b/src/main/java/ch/sbb/pfi/netzgrafikeditor/converter/io/gtfs/GtfsScheduleWriter.java @@ -1,79 +1,132 @@ package ch.sbb.pfi.netzgrafikeditor.converter.io.gtfs; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Agency; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Calendar; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.FeedInfo; import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.GtfsSchedule; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Route; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Stop; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.StopTime; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Trip; import ch.sbb.pfi.netzgrafikeditor.converter.core.ConverterSink; +import lombok.AccessLevel; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import java.io.BufferedWriter; import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; @RequiredArgsConstructor @Slf4j public class GtfsScheduleWriter implements ConverterSink { - private final Path directory; - - @Override - public void save(GtfsSchedule result) throws IOException { - Files.createDirectories(directory); + public static final String GTFS_ZIP = "gtfs.zip"; + public static final String DELIMITER = ","; + public static final String NA_VALUE = ""; - writeListToFile(result.getRoutes(), "routes.txt"); - writeListToFile(result.getAgencies(), "agencies.txt"); - writeListToFile(result.getStops(), "stops.txt"); - writeListToFile(result.getTrips(), "trips.txt"); - writeListToFile(result.getStopTimes(), "stop_times.txt"); - writeListToFile(result.getCalendars(), "calendars.txt"); - } + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - private void writeListToFile(List list, String fileName) throws IOException { - if (list == null || list.isEmpty()) { - return; - } + private final Path directory; + private final boolean zip; - Path filePath = directory.resolve(fileName); - try (BufferedWriter writer = Files.newBufferedWriter(filePath)) { - Class clazz = list.getFirst().getClass(); - Field[] fields = clazz.getDeclaredFields(); + private static void writeList(List list, Class clazz, OutputStream os) throws IOException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os)); + Field[] fields = clazz.getDeclaredFields(); + try { // write header String header = generateHeader(fields); writer.write(header); writer.newLine(); + // check for empty list + if (list == null || list.isEmpty()) { + log.warn("Writing empty CSV file: {}", clazz.getSimpleName()); + return; + } + // write data for (T item : list) { String data = generateDataLine(item, fields); writer.write(data); writer.newLine(); } + + } finally { + // ensure all data is written but do not close the writer since it would also close th underlying output + // stream, which has to stay open in the case of a zip file. + writer.flush(); } } - private String generateHeader(Field[] fields) { - return Arrays.stream(fields).map(Field::getName).map(this::camelToSnakeCase).collect(Collectors.joining(",")); + private static String generateHeader(Field[] fields) { + return filterInstanceFields(fields).map(Field::getName) + .map(GtfsScheduleWriter::camelToSnakeCase) + .collect(Collectors.joining(DELIMITER)); } - private String generateDataLine(T item, Field[] fields) { - return Arrays.stream(fields).map(field -> { + private static String generateDataLine(T item, Field[] fields) { + return filterInstanceFields(fields).map(field -> { field.setAccessible(true); + try { - return field.get(item) != null ? field.get(item).toString() : ""; + Object value = field.get(item); + if (value instanceof LocalDate casted) { + return casted.format(DATE_FORMATTER); + } else if (value instanceof Calendar.Type casted) { + return casted == Calendar.Type.AVAILABLE ? "1" : "0"; + } else if (value != null) { + return escapeCsv(value.toString()); + } else { + return NA_VALUE; + } } catch (IllegalAccessException e) { log.error("Error accessing field value", e); - return ""; + return NA_VALUE; } - }).collect(Collectors.joining(",")); + + }).collect(Collectors.joining(DELIMITER)); + } + + private static String escapeCsv(String value) { + value = value.replace("\n", "").replace("\r", ""); + + if (value.contains(DELIMITER) || value.contains("\"")) { + // escape double quotes by doubling them + value = value.replace("\"", "\"\""); + + // enclose the entire field in double quotes + return "\"" + value + "\""; + } + + return value; + } + + private static Stream filterInstanceFields(Field[] fields) { + return Arrays.stream(fields) + .filter(field -> Modifier.isPrivate(field.getModifiers()) && !(Modifier.isStatic( + field.getModifiers()) && Modifier.isFinal(field.getModifiers()))); } - private String camelToSnakeCase(String camelCase) { + private static String camelToSnakeCase(String camelCase) { StringBuilder result = new StringBuilder(); + for (char c : camelCase.toCharArray()) { if (Character.isUpperCase(c)) { result.append('_').append(Character.toLowerCase(c)); @@ -81,6 +134,66 @@ private String camelToSnakeCase(String camelCase) { result.append(c); } } + return result.toString(); } + + @Override + public void save(GtfsSchedule result) throws IOException { + Files.createDirectories(directory); + + if (zip) { + Path zipFilePath = directory.resolve(GTFS_ZIP); + try (ZipOutputStream zipOutputStream = new ZipOutputStream( + Files.newOutputStream(zipFilePath, StandardOpenOption.CREATE))) { + writeToZip(List.of(result.getFeedInfo()), zipOutputStream, GtfsFile.FEED_INFO); + writeToZip(result.getAgencies(), zipOutputStream, GtfsFile.AGENCY); + writeToZip(result.getStops(), zipOutputStream, GtfsFile.STOPS); + writeToZip(result.getRoutes(), zipOutputStream, GtfsFile.ROUTES); + writeToZip(result.getTrips(), zipOutputStream, GtfsFile.TRIPS); + writeToZip(result.getStopTimes(), zipOutputStream, GtfsFile.STOP_TIMES); + writeToZip(result.getCalendars(), zipOutputStream, GtfsFile.CALENDAR); + } + } else { + writeToFile(List.of(result.getFeedInfo()), GtfsFile.FEED_INFO); + writeToFile(result.getAgencies(), GtfsFile.AGENCY); + writeToFile(result.getStops(), GtfsFile.STOPS); + writeToFile(result.getRoutes(), GtfsFile.ROUTES); + writeToFile(result.getTrips(), GtfsFile.TRIPS); + writeToFile(result.getStopTimes(), GtfsFile.STOP_TIMES); + writeToFile(result.getCalendars(), GtfsFile.CALENDAR); + } + } + + private void writeToZip(List list, ZipOutputStream zipOutputStream, GtfsFile gtfsFile) throws IOException { + ZipEntry zipEntry = new ZipEntry(gtfsFile.fileName); + zipOutputStream.putNextEntry(zipEntry); + + writeList(list, gtfsFile.clazz, zipOutputStream); + + zipOutputStream.closeEntry(); + } + + private void writeToFile(List list, GtfsFile gtfsFile) throws IOException { + Path filePath = directory.resolve(gtfsFile.fileName); + + try (OutputStream writer = Files.newOutputStream(filePath, StandardOpenOption.CREATE)) { + writeList(list, gtfsFile.clazz, writer); + } + } + + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @Getter + enum GtfsFile { + AGENCY("agency.txt", Agency.class), + STOPS("stops.txt", Stop.class), + ROUTES("routes.txt", Route.class), + TRIPS("trips.txt", Trip.class), + STOP_TIMES("stop_times.txt", StopTime.class), + CALENDAR("calendar.txt", Calendar.class), + FEED_INFO("feed_info.txt", FeedInfo.class); + + private final String fileName; + private final Class clazz; + } } diff --git a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilderTest.java b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilderTest.java index 8d47024..abd669b 100644 --- a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilderTest.java +++ b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/adapter/gtfs/GtfsSupplyBuilderTest.java @@ -5,6 +5,7 @@ import ch.sbb.pfi.netzgrafikeditor.converter.core.supply.DepartureInfo; import ch.sbb.pfi.netzgrafikeditor.converter.core.supply.InfrastructureRepository; import ch.sbb.pfi.netzgrafikeditor.converter.core.supply.StopFacilityInfo; +import ch.sbb.pfi.netzgrafikeditor.converter.core.supply.TransitLineInfo; import ch.sbb.pfi.netzgrafikeditor.converter.core.supply.TransitRouteInfo; import ch.sbb.pfi.netzgrafikeditor.converter.core.supply.VehicleAllocation; import ch.sbb.pfi.netzgrafikeditor.converter.core.supply.VehicleCircuitsPlanner; @@ -49,7 +50,8 @@ void setUp() { when(infrastructureRepository.getStopFacility(eq("d"), any(Double.class), any(Double.class))).thenReturn( new StopFacilityInfo("d", new Coordinate(4, 4))); - TransitRouteInfo transitRouteInfo = new TransitRouteInfo("routeId", null); + TransitLineInfo transitLineInfo = new TransitLineInfo("lineId", null); + TransitRouteInfo transitRouteInfo = new TransitRouteInfo("routeId", transitLineInfo); DepartureInfo departureInfo = new DepartureInfo(transitRouteInfo, ServiceDayTime.NOON); VehicleAllocation vehicleAllocation = new VehicleAllocation(null, departureInfo, null); diff --git a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/app/CommandLineConverterIT.java b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/app/CommandLineConverterIT.java index c2b097e..53c09b0 100644 --- a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/app/CommandLineConverterIT.java +++ b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/app/CommandLineConverterIT.java @@ -1,5 +1,6 @@ package ch.sbb.pfi.netzgrafikeditor.converter.app; +import ch.sbb.pfi.netzgrafikeditor.converter.util.test.TestDirectoryExtension; import lombok.AccessLevel; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -14,42 +15,29 @@ import org.springframework.test.context.ActiveProfiles; import java.io.IOException; +import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; -import java.util.Comparator; import java.util.List; -import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +@ExtendWith(TestDirectoryExtension.class) @ExtendWith(OutputCaptureExtension.class) @SpringBootTest(classes = CommandLineConverter.class) @ActiveProfiles("test") class CommandLineConverterIT { private static final Path NETWORK_GRAPHIC_FILE = Path.of("src/test/resources/ng/scenarios/realistic.json"); - private static final String OUTPUT_ROOT = "integration-test/output/"; - private static final Path OUTPUT_PATH = Path.of( - OUTPUT_ROOT + CommandLineConverterIT.class.getCanonicalName().replace(".", "/")); private final List args = new ArrayList<>(); - - private static void cleanup(Path path) throws IOException { - if (Files.exists(path)) { - try (Stream paths = Files.walk(path)) { - paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(file -> { - if (!file.delete()) { - throw new RuntimeException("Failed to delete " + file); - } - }); - } - } - } + private Path outputDir; @BeforeEach - void setUp() { + void setUp(Path outputDir) { + this.outputDir = outputDir; args.add(NETWORK_GRAPHIC_FILE.toString()); } @@ -69,9 +57,7 @@ void testConvertCommand_matsim(TestCase testCase, CapturedOutput output) throws private void runConverter(TestCase testCase, CapturedOutput output, String format) throws IOException { // arrange - Path outputPath = OUTPUT_PATH.resolve(format.toLowerCase()).resolve(testCase.name().toLowerCase()); - cleanup(outputPath); - args.add(outputPath.toString()); + args.add(outputDir.toString()); args.addAll(List.of("-f", format)); args.addAll(Arrays.asList(testCase.args)); @@ -82,11 +68,13 @@ private void runConverter(TestCase testCase, CapturedOutput output, String forma // assert if (testCase.success) { assertThat(exitCode).isEqualTo(0); - assertThat(Files.exists(outputPath)).isTrue(); - assertThat(Files.list(outputPath)).isNotEmpty(); + assertThat(Files.exists(outputDir)).isTrue(); + assertThat(Files.list(outputDir)).isNotEmpty(); } else { assertThat(exitCode).isEqualTo(1); - assertThat(Files.exists(outputPath)).isFalse(); + try (DirectoryStream stream = Files.newDirectoryStream(outputDir)) { + assertThat(stream.iterator().hasNext()).isFalse(); + } assertThat(output).contains(testCase.exception); } } diff --git a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/core/NetworkGraphicConverterIT.java b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/core/NetworkGraphicConverterIT.java index 449d8bb..46ac7c0 100644 --- a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/core/NetworkGraphicConverterIT.java +++ b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/core/NetworkGraphicConverterIT.java @@ -11,7 +11,10 @@ import ch.sbb.pfi.netzgrafikeditor.converter.io.gtfs.GtfsScheduleWriter; import ch.sbb.pfi.netzgrafikeditor.converter.io.matsim.TransitScheduleXmlWriter; import ch.sbb.pfi.netzgrafikeditor.converter.io.netzgrafik.JsonFileReader; +import ch.sbb.pfi.netzgrafikeditor.converter.util.test.TestDirectoryExtension; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.matsim.api.core.v01.Id; @@ -25,14 +28,19 @@ import static org.junit.jupiter.api.Assertions.*; +@ExtendWith(TestDirectoryExtension.class) public class NetworkGraphicConverterIT { - public static final String OUTPUT_ROOT = "integration-test/output/"; - public static final Path OUTPUT_PATH = Path.of( - OUTPUT_ROOT + NetworkGraphicConverterIT.class.getCanonicalName().replace(".", "/")); public static final String CASE_SEPARATOR = "."; public static final String DELIMITER = "-"; + private Path outputDir; + + @BeforeEach + void setUp(Path outputDir) { + this.outputDir = outputDir; + } + @Nested class MatsimTransitSchedule { private Scenario scenario; @@ -55,6 +63,7 @@ void run(TestCase testCase) throws IOException { } private void validate(TestCase testCase) { + // check scenario assertNotNull(scenario); assertEquals(1, scenario.getTransitSchedule().getTransitLines().size()); @@ -98,8 +107,7 @@ private void configure(Path path, String prefix) { // store scenario and write schedule ConverterSink sink = result -> { scenario = result; - new TransitScheduleXmlWriter(OUTPUT_PATH.resolve(prefix.toLowerCase()), - prefix.toLowerCase() + CASE_SEPARATOR).save(scenario); + new TransitScheduleXmlWriter(outputDir, prefix.toLowerCase() + CASE_SEPARATOR).save(result); }; converter = new NetworkGraphicConverter<>(config, source, builder, sink); @@ -121,7 +129,7 @@ private static void validateCurrentSequence(TestCase testCase, String actualSequ @ParameterizedTest @EnumSource(TestScenario.class) void run(TestScenario testScenario) throws IOException { - configure(testScenario.getPath(), testScenario.name()); + configure(testScenario.getPath()); converter.run(); assertNotNull(schedule); } @@ -129,7 +137,7 @@ void run(TestScenario testScenario) throws IOException { @ParameterizedTest @EnumSource(TestCase.class) void run(TestCase testCase) throws IOException { - configure(testCase.getPath(), testCase.name()); + configure(testCase.getPath()); converter.run(); validate(testCase); } @@ -139,7 +147,7 @@ private void validate(TestCase testCase) { assertEquals(1, schedule.getAgencies().size()); assertEquals(1, schedule.getCalendars().size()); assertEquals(1, schedule.getRoutes().size()); - assertEquals(2, schedule.getTrips().size()); + assertFalse(schedule.getTrips().isEmpty()); StringBuilder sb = new StringBuilder(); boolean first = true; @@ -147,7 +155,7 @@ private void validate(TestCase testCase) { for (StopTime stopTime : schedule.getStopTimes()) { // end of current sequence - if (stopTime.getStopSequence() == 0 && !first) { + if (stopTime.getStopSequence() == 1 && !first) { validateCurrentSequence(testCase, sb.toString(), reversed); // reset @@ -156,7 +164,7 @@ private void validate(TestCase testCase) { } // store direction - reversed = stopTime.getTripId().endsWith(RouteDirection.REVERSE.name()); + reversed = stopTime.getTripId().contains(RouteDirection.REVERSE.name()); // add new stop id if (!sb.isEmpty()) { @@ -171,7 +179,7 @@ private void validate(TestCase testCase) { } - private void configure(Path path, String prefix) { + private void configure(Path path) { NetworkGraphicConverterConfig config = NetworkGraphicConverterConfig.builder() .useTrainNamesAsIds(true) .build(); @@ -183,7 +191,7 @@ private void configure(Path path, String prefix) { // store and write schedule ConverterSink sink = result -> { schedule = result; - new GtfsScheduleWriter(OUTPUT_PATH.resolve(prefix.toLowerCase())).save(schedule); + new GtfsScheduleWriter(outputDir, false).save(result); }; converter = new NetworkGraphicConverter<>(config, source, builder, sink); diff --git a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/io/gtfs/GtfsScheduleWriterTest.java b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/io/gtfs/GtfsScheduleWriterTest.java new file mode 100644 index 0000000..126c210 --- /dev/null +++ b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/io/gtfs/GtfsScheduleWriterTest.java @@ -0,0 +1,90 @@ +package ch.sbb.pfi.netzgrafikeditor.converter.io.gtfs; + +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Agency; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Calendar; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.GtfsSchedule; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Route; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Stop; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.StopTime; +import ch.sbb.pfi.netzgrafikeditor.converter.adapter.gtfs.model.Trip; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GtfsScheduleWriterTest { + + private static final GtfsSchedule SCHEDULE = GtfsSchedule.builder() + .agencies(List.of(Agency.builder().build())) + .stops(List.of(Stop.builder().build())) + .routes(List.of(Route.builder().build())) + .trips(List.of(Trip.builder().build())) + .stopTimes(List.of(StopTime.builder().build())) + .calendars(List.of(Calendar.builder().build())) + .build(); + + private static final GtfsSchedule SCHEDULE_EMPTY = GtfsSchedule.builder().build(); + + private GtfsScheduleWriter writer; + private Path outputDir; + + @Nested + class Csv { + + @BeforeEach + void setUp(@TempDir Path tempDir) { + outputDir = tempDir; + writer = new GtfsScheduleWriter(outputDir, false); + } + + @Test + void save() throws IOException { + writer.save(SCHEDULE); + ensureFiles(); + } + + @Test + void save_empty() throws IOException { + writer.save(SCHEDULE_EMPTY); + ensureFiles(); + } + + private void ensureFiles() { + for (GtfsScheduleWriter.GtfsFile file : GtfsScheduleWriter.GtfsFile.values()) { + assertTrue(outputDir.resolve(file.getFileName()).toFile().exists()); + } + } + } + + @Nested + class Zip { + + @BeforeEach + void setUp(@TempDir Path tempDir) { + outputDir = tempDir; + writer = new GtfsScheduleWriter(outputDir, true); + } + + @Test + void save() throws IOException { + writer.save(SCHEDULE); + ensureZip(); + } + + @Test + void save_empty() throws IOException { + writer.save(SCHEDULE_EMPTY); + ensureZip(); + } + + private void ensureZip() { + assertTrue(outputDir.resolve(GtfsScheduleWriter.GTFS_ZIP).toFile().exists()); + } + } +} diff --git a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/util/test/TestDirectoryExtension.java b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/util/test/TestDirectoryExtension.java new file mode 100644 index 0000000..9e7ea6d --- /dev/null +++ b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/util/test/TestDirectoryExtension.java @@ -0,0 +1,132 @@ +package ch.sbb.pfi.netzgrafikeditor.converter.util.test; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Adds a test directory to the annotated test. + *

+ * Each test case will have a directory structured by the fully specified class path and the method name. Nested tests + * will create subdirectories. For example: + *

+ * integration-test/
+ * ├── input/
+ * │   └── com/example/SomeTest/
+ * │       └── testMethod/
+ * └── output/
+ *     └── com/example/SomeTest/
+ *         └── testMethod/
+ * 
+ *

+ * Usage: Extend a test class with the extension and inject the input and output directories into the test methods. The + * output directory will be created and emptied before each test execution.The input directory should be set up manually + * and versioned (e.g., committed to a Git repository) to ensure consistency for each test case. The output directory is + * managed by the test itself and should not be versioned (e.g. add to .gitignore). + *

+ * Example: + *

+ * @ExtendWith(TestDirectoryExtension.class)
+ * public class SomeTest {
+ *
+ *     @Test
+ *     void testMethod(Path inputRootDir, Path inputDir, Path outputDir) {
+ *
+ *     }
+ *
+ * }
+ * 
+ *

+ * Note: Use {@link BeforeEachCallback} instead of {@link BeforeTestExecutionCallback} to make the parameters available + * in the {@link BeforeEach} annotated setup methods. + *

+ */ +public class TestDirectoryExtension implements BeforeEachCallback, ParameterResolver { + + private static final Path ROOT_PATH = Paths.get("integration-test"); + private static final Path INPUT_BASE_PATH = ROOT_PATH.resolve("input"); + private static final Path OUTPUT_BASE_PATH = ROOT_PATH.resolve("output"); + + private static final String OUTPUT_DIR = "outputDir"; + private static final String INPUT_DIR = "inputDir"; + private static final String INPUT_ROOT_DIR = "inputRootDir"; + + private static String getClassPath(ExtensionContext context) { + return context.getTestClass() + .map(Class::getName) + .orElseThrow(() -> new IllegalStateException("Test class not found")) + .replace('.', '/') + .replace("$", "/"); + } + + private static String getMethodName(ExtensionContext context) { + return context.getTestMethod() + .map(Method::getName) + .orElseThrow(() -> new IllegalStateException("Test method not found")); + } + + private static Path handleParameterizedTest(ExtensionContext context, Path outputDir) { + if (context.getTestMethod() + .map(method -> method.isAnnotationPresent(org.junit.jupiter.params.ParameterizedTest.class)) + .orElse(false)) { + outputDir = outputDir.resolve(context.getDisplayName()); + } + return outputDir; + } + + private static void deleteDirectory(Path path) throws IOException { + if (Files.isDirectory(path)) { + try (DirectoryStream entries = Files.newDirectoryStream(path)) { + for (Path entry : entries) { + deleteDirectory(entry); + } + } + } + Files.delete(path); + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + String classPath = getClassPath(context); + String methodName = getMethodName(context); + + Path inputDir = INPUT_BASE_PATH.resolve(classPath).resolve(methodName); + Path outputDir = handleParameterizedTest(context, OUTPUT_BASE_PATH.resolve(classPath).resolve(methodName)); + + context.getStore(ExtensionContext.Namespace.GLOBAL).put(INPUT_DIR, inputDir); + context.getStore(ExtensionContext.Namespace.GLOBAL).put(OUTPUT_DIR, outputDir); + + if (Files.exists(outputDir)) { + deleteDirectory(outputDir); + } + Files.createDirectories(outputDir); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return parameterContext.getParameter().getType().equals(Path.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + String parameterName = parameterContext.getParameter().getName(); + ExtensionContext.Store store = extensionContext.getStore(ExtensionContext.Namespace.GLOBAL); + + return switch (parameterName) { + case OUTPUT_DIR -> store.get(OUTPUT_DIR, Path.class); + case INPUT_DIR -> store.get(INPUT_DIR, Path.class); + case INPUT_ROOT_DIR -> INPUT_BASE_PATH; + default -> throw new IllegalArgumentException("Unsupported parameter: " + parameterName); + }; + } +} diff --git a/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/util/test/TestDirectoryExtensionIT.java b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/util/test/TestDirectoryExtensionIT.java new file mode 100644 index 0000000..7030b83 --- /dev/null +++ b/src/test/java/ch/sbb/pfi/netzgrafikeditor/converter/util/test/TestDirectoryExtensionIT.java @@ -0,0 +1,67 @@ +package ch.sbb.pfi.netzgrafikeditor.converter.util.test; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(TestDirectoryExtension.class) +class TestDirectoryExtensionIT { + + public static final String DUMMY_FILE = "dummy.txt"; + public static final String DUMMY_CONTENT = "Dummy content..."; + + private static void runTestProcedure(Path inputRootDir, Path inputDir, Path outputDir) throws IOException { + // input directories + assertFalse(Files.isDirectory(inputRootDir)); + assertFalse(Files.isDirectory(inputDir)); + + // ensure output directory has been created and is empty + assertTrue(Files.isDirectory(outputDir)); + try (Stream files = Files.list(outputDir)) { + assertTrue(files.toList().isEmpty()); + } + + // write dummy file + writeDummyTxt(outputDir); + assertTrue(Files.exists(outputDir.resolve(DUMMY_FILE))); + } + + private static void writeDummyTxt(Path directory) throws IOException { + Files.write(directory.resolve(DUMMY_FILE), DUMMY_CONTENT.getBytes(), StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } + + @Test + public void testA(Path inputRootDir, Path inputDir, Path outputDir) throws IOException { + runTestProcedure(inputRootDir, inputDir, outputDir); + } + + @Test + public void testB(Path inputRootDir, Path inputDir, Path outputDir) throws IOException { + runTestProcedure(inputRootDir, inputDir, outputDir); + } + + @Nested + class NestedTest { + + @Test + public void testC(Path inputRootDir, Path inputDir, Path outputDir) throws IOException { + runTestProcedure(inputRootDir, inputDir, outputDir); + } + + @Test + public void testD(Path inputRootDir, Path inputDir, Path outputDir) throws IOException { + runTestProcedure(inputRootDir, inputDir, outputDir); + } + + } +}