Files
RutasDBUS/tests/RutasDBUS.RegressionTests/Program.cs

755 lines
38 KiB
C#

using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using RutasDBUS.Components.Pages;
using RutasDBUS.Modelos.Historial;
using RutasDBUS.Modelos.Importacion;
using RutasDBUS.Modelos.Planificacion;
using RutasDBUS.Servicios;
using RutasDBUS.Servicios.Caminata;
using RutasDBUS.Servicios.Configuracion;
using RutasDBUS.Servicios.Horarios;
using RutasDBUS.Servicios.Importacion;
using RutasDBUS.Servicios.Planificacion;
static void Check(bool result, string message)
{
if (!result) throw new Exception(message);
Console.WriteLine("OK " + message);
}
var baseMethod = typeof(PlanificadorRutas).GetMethod("CrearConfiguracionBaseHistorial", BindingFlags.Static | BindingFlags.NonPublic)!;
var defaults = (ConfiguracionHistorialRuta)baseMethod.Invoke(null, null)!;
Check(defaults.PesoTransbordoRankingMinutos == 6, "Penalizacion inicial de 6 minutos");
var calendariosTaxibus = new List<FilaCalendarioCsv>
{
new("LAB_BASE", 2, 2, 2, 2, 2, 1, 1, new DateOnly(2026, 6, 1), new DateOnly(2026, 12, 31)),
new("FEST_BASE", 1, 1, 1, 1, 1, 1, 2, new DateOnly(2026, 6, 1), new DateOnly(2026, 12, 31))
};
var excepcionesTaxibus = new List<FilaCalendarioExcepcionCsv>
{
new("FEST_BASE", new DateOnly(2026, 10, 12), 1)
};
var rutasTaxibus = new List<FilaRutaCsv>
{
new("6", "TB6", "Ulia", "Ulia", "4", "", "94AF5D"),
new("7", "TB7", "Atotxaerreka", "Atotxaerreka", "4", "", "63C600")
};
var expedicionesTaxibus = new List<FilaExpedicionCsv>
{
new("TB6_I", "6", "N.A.", 1, "6", "60101", null, "0000"),
new("TB6_V", "6", "N.A.", 2, "6", "60102", null, "0000"),
new("TB7_701_I", "7", "N.A.", 1, "7", "70101", null, "0000"),
new("TB7_701_V", "7", "N.A.", 2, "7", "70102", null, "0000"),
new("TB7_702_I", "7", "N.A.", 1, "7", "70201", null, "0000"),
new("TB7_702_V", "7", "N.A.", 2, "7", "70202", null, "0000")
};
var tiemposTaxibus = new List<FilaTiempoParadaCsv>
{
new("TB6_I", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 372, 1, "", "", ""),
new("TB6_I", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 346, 2, "", "", ""),
new("TB6_I", TimeOnly.MinValue, TimeOnly.MinValue, 0, 521, 3, "", "", ""),
new("TB6_V", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 521, 1, "", "", ""),
new("TB6_V", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 457, 2, "", "", ""),
new("TB6_V", TimeOnly.MinValue, TimeOnly.MinValue, 0, 372, 3, "", "", ""),
new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 800, 240, 1, "", "", ""),
new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 300, 617, 2, "", "", ""),
new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 400, 620, 3, "", "", ""),
new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 0, 599, 4, "", "", ""),
new("TB7_701_V", TimeOnly.MinValue, TimeOnly.MinValue, 200, 599, 1, "", "", ""),
new("TB7_701_V", TimeOnly.MinValue, TimeOnly.MinValue, 1300, 343, 2, "", "", ""),
new("TB7_701_V", TimeOnly.MinValue, TimeOnly.MinValue, 0, 42, 3, "", "", ""),
new("TB7_702_I", TimeOnly.MinValue, TimeOnly.MinValue, 2000, 42, 1, "", "", ""),
new("TB7_702_I", TimeOnly.MinValue, TimeOnly.MinValue, 0, 625, 2, "", "", ""),
new("TB7_702_V", TimeOnly.MinValue, TimeOnly.MinValue, 1500, 625, 1, "", "", ""),
new("TB7_702_V", TimeOnly.MinValue, TimeOnly.MinValue, 0, 240, 2, "", "", "")
};
var integrarTaxibus = typeof(TaxibusDbus).Assembly
.GetType("RutasDBUS.Servicios.Importacion.HorariosOficialesTaxibus")!
.GetMethod("AplicarSiFaltanHorarios", BindingFlags.Static | BindingFlags.Public)!;
var rutasTaxibusIntegradas = (IReadOnlySet<string>)integrarTaxibus.Invoke(
null,
new object[]
{
calendariosTaxibus,
excepcionesTaxibus,
expedicionesTaxibus,
rutasTaxibus,
tiemposTaxibus
})!;
Check(rutasTaxibusIntegradas.SetEquals(new[] { "6", "7" }),
"TB6 y TB7 reciben sus horarios oficiales cuando faltan en el ZIP");
Check(expedicionesTaxibus.Any(expedicion =>
expedicion.IdRuta == "6" && expedicion.HSalida == new TimeOnly(7, 0)) &&
expedicionesTaxibus.Any(expedicion =>
expedicion.IdRuta == "6" && expedicion.HSalida == new TimeOnly(7, 40)),
"TB6 sale a las horas oficiales desde Colon y Albergue");
var salidaTb6Colon = expedicionesTaxibus.Single(expedicion =>
expedicion.IdRuta == "6" &&
expedicion.IdPatronExp == "60101" &&
expedicion.HSalida == new TimeOnly(7, 0));
Check(tiemposTaxibus.Any(tiempo =>
tiempo.IdExp == salidaTb6Colon.Id &&
tiempo.IdTiempoPar == 346 &&
tiempo.HoraSalida == new TimeOnly(7, 10)) &&
tiemposTaxibus.Any(tiempo =>
tiempo.IdExp == salidaTb6Colon.Id &&
tiempo.IdTiempoPar == 521 &&
tiempo.HoraSalida == new TimeOnly(7, 20)) &&
expedicionesTaxibus.Any(expedicion =>
expedicion.IdPatronExp == "60101" &&
expedicion.HSalida == new TimeOnly(22, 0)),
"TB6 respeta los tiempos publicados y la salida adicional de verano");
var salidaTb7Laborable = expedicionesTaxibus.Single(expedicion =>
expedicion.IdRuta == "7" &&
expedicion.IdPatronExp == "70101" &&
expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) &&
expedicion.HSalida == new TimeOnly(7, 15));
Check(tiemposTaxibus.Any(tiempo =>
tiempo.IdExp == salidaTb7Laborable.Id &&
tiempo.IdTiempoPar == 617 &&
tiempo.HoraSalida == new TimeOnly(7, 23)) &&
tiemposTaxibus.Any(tiempo =>
tiempo.IdExp == salidaTb7Laborable.Id &&
tiempo.IdTiempoPar == 599 &&
tiempo.HoraSalida == new TimeOnly(7, 30)),
"TB7 respeta las horas oficiales de Antiguo, Atotxaerreka y Anorga");
var salidaTb7Anorga = expedicionesTaxibus.Single(expedicion =>
expedicion.IdPatronExp == "70102" &&
expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) &&
expedicion.HSalida == new TimeOnly(7, 30));
var salidaTb7Faro = expedicionesTaxibus.Single(expedicion =>
expedicion.IdPatronExp == "70201" &&
expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) &&
expedicion.HSalida == new TimeOnly(7, 45));
var vueltaTb7Faro = expedicionesTaxibus.Single(expedicion =>
expedicion.IdPatronExp == "70202" &&
expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) &&
expedicion.HSalida == new TimeOnly(8, 5));
Check(tiemposTaxibus.Any(tiempo =>
tiempo.IdExp == salidaTb7Anorga.Id &&
tiempo.IdTiempoPar == 42 &&
tiempo.HoraSalida == new TimeOnly(7, 45)) &&
tiemposTaxibus.Any(tiempo =>
tiempo.IdExp == salidaTb7Faro.Id &&
tiempo.IdTiempoPar == 625 &&
tiempo.HoraSalida == new TimeOnly(8, 5)) &&
tiemposTaxibus.Any(tiempo =>
tiempo.IdExp == vueltaTb7Faro.Id &&
tiempo.IdTiempoPar == 240 &&
tiempo.HoraSalida == new TimeOnly(8, 15)),
"TB7 mantiene encadenado el ciclo Anorga, Antiguo y Paseo del Faro");
Check(expedicionesTaxibus.Any(expedicion =>
expedicion.IdPatronExp == "70101" &&
expedicion.IdCal.EndsWith("SAB", StringComparison.Ordinal) &&
expedicion.HSalida == new TimeOnly(8, 15)) &&
expedicionesTaxibus.Any(expedicion =>
expedicion.IdPatronExp == "70101" &&
expedicion.IdCal.EndsWith("FEST", StringComparison.Ordinal) &&
expedicion.HSalida == new TimeOnly(9, 15)),
"TB7 diferencia laborables, sabados y festivos");
Check(excepcionesTaxibus.Any(excepcion =>
excepcion.Id.EndsWith("LAB", StringComparison.Ordinal) &&
excepcion.FechaEx == new DateOnly(2026, 10, 12) &&
excepcion.TipoExcepcion == 2) &&
excepcionesTaxibus.Any(excepcion =>
excepcion.Id.EndsWith("FEST", StringComparison.Ordinal) &&
excepcion.FechaEx == new DateOnly(2026, 10, 12) &&
excepcion.TipoExcepcion == 1),
"Los festivos entre semana usan el horario festivo de TB7");
var salidaVeranoAtotxaerreka = expedicionesTaxibus.Single(expedicion =>
expedicion.IdPatronExp == "70101" &&
expedicion.HSalida == new TimeOnly(22, 15));
Check(tiemposTaxibus.Where(tiempo => tiempo.IdExp == salidaVeranoAtotxaerreka.Id)
.OrderBy(tiempo => tiempo.SecPar)
.Last().IdTiempoPar == 620 &&
expedicionesTaxibus.Any(expedicion =>
expedicion.IdPatronExp == "70201" &&
expedicion.HSalida == new TimeOnly(21, 45)),
"TB7 incluye las ampliaciones oficiales de verano sin prolongar el viaje de Atotxaerreka");
var expedicionesAnalisisTramos = new List<FilaExpedicionCsv>
{
new("E1", "28", "Amara", 1, "28", "280101", new TimeOnly(8, 0), "LAB"),
new("E2", "28", "Amara", 1, "28", "280101", new TimeOnly(9, 0), "LAB"),
new("E3", "28", "Amara", 1, "28", "280101", new TimeOnly(10, 0), "LAB"),
new("E4", "28", "Amara", 1, "28", "280101", new TimeOnly(11, 0), "LAB"),
new("PLANTILLA", "28", "Amara", 1, "28", "280101", null, "LAB"),
new("OTRA_VARIANTE", "28", "Boulevard", 2, "28", "280102", new TimeOnly(8, 0), "LAB")
};
var tiemposAnalisisTramos = new List<FilaTiempoParadaCsv>
{
new("E1", new TimeOnly(8, 0), new TimeOnly(8, 0), 100, 1, 1, "", "", ""),
new("E1", new TimeOnly(8, 2), new TimeOnly(8, 2), 100, 2, 2, "", "", ""),
new("E1", new TimeOnly(8, 3), new TimeOnly(8, 3), 0, 3, 3, "", "", ""),
new("E2", new TimeOnly(9, 0), new TimeOnly(9, 0), 100, 1, 1, "", "", ""),
new("E2", new TimeOnly(9, 2, 30), new TimeOnly(9, 2, 30), 100, 2, 2, "", "", ""),
new("E2", new TimeOnly(9, 3, 40), new TimeOnly(9, 3, 40), 0, 3, 3, "", "", ""),
new("E3", new TimeOnly(10, 0), new TimeOnly(10, 0), 100, 1, 1, "", "", ""),
new("E3", new TimeOnly(10, 2, 59), new TimeOnly(10, 2, 59), 100, 2, 2, "", "", ""),
new("E3", new TimeOnly(10, 6, 9), new TimeOnly(10, 6, 9), 0, 3, 3, "", "", ""),
new("E4", new TimeOnly(11, 0), new TimeOnly(11, 0), 100, 1, 1, "", "", ""),
new("E4", new TimeOnly(11, 4, 10), new TimeOnly(11, 4, 10), 100, 2, 2, "", "", ""),
new("E4", new TimeOnly(11, 5, 15), new TimeOnly(11, 5, 15), 0, 3, 3, "", "", "")
};
var archivoAnalisisTramos = new ArchivoImportacionDbus
{
Calendarios =
[
new FilaCalendarioCsv(
"LAB", 2, 2, 2, 2, 2, 1, 1,
new DateOnly(2026, 1, 1), new DateOnly(2026, 12, 31))
],
Expediciones = expedicionesAnalisisTramos,
Rutas = [new FilaRutaCsv("28", "28", "Amara-Ospitaleak", "", "4", "", "F39E00")],
Paradas =
[
new FilaParadaCsv(1, "101", "Origen", 0, 0, "", "", "", "", "", ""),
new FilaParadaCsv(2, "102", "Intermedia", 0, 0, "", "", "", "", "", ""),
new FilaParadaCsv(3, "103", "Destino", 0, 0, "", "", "", "", "", "")
],
TiemposParada = tiemposAnalisisTramos,
TiemposParadaPorExpedicion = tiemposAnalisisTramos
.GroupBy(x => x.IdExp, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.OrderBy(x => x.SecPar).ToList(),
StringComparer.OrdinalIgnoreCase)
};
var servicioAnalisisTramos = new ServicioHorariosDbus(
new FakeArchivoImportacionDbus(archivoAnalisisTramos));
await servicioAnalisisTramos.AsegurarCargaAsync("");
var analisisTramos = servicioAnalisisTramos.AnalizarTramosItinerario(
"28", "28", 1, "I", 280101, new DateOnly(2026, 9, 17));
Check(analisisTramos.ExpedicionesAnalizadas == 4 && analisisTramos.Tramos.Count == 2,
"El analisis de tramos usa solo las expediciones activas de la variante exacta");
Check(analisisTramos.Tramos[0].Repeticiones == 3 &&
analisisTramos.Tramos[0].ExpedicionesAnalizadas == 4 &&
analisisTramos.Tramos[0].DuracionHabitualSegundos == 150,
"Los tiempos con hasta un minuto de diferencia forman la frecuencia dominante");
var panelSinCambios = new PlanificadorRutas();
var alternarPanel = typeof(PlanificadorRutas).GetMethod(
"ToggleConfigPanel",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var solicitarCierre = typeof(PlanificadorRutas).GetMethod(
"SolicitarCierreConfiguracionAvanzada",
BindingFlags.Instance | BindingFlags.NonPublic)!;
alternarPanel.Invoke(panelSinCambios, null);
Check(
solicitarCierre.Invoke(panelSinCambios, null) is true,
"Configuracion avanzada sin cambios se cierra directamente");
var panelConCambios = new PlanificadorRutas();
alternarPanel.Invoke(panelConCambios, null);
typeof(PlanificadorRutas)
.GetField("_velocidadPieKmH", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(panelConCambios, 4.2);
Check(
solicitarCierre.Invoke(panelConCambios, null) is false &&
(bool)typeof(PlanificadorRutas)
.GetField("_mostrarConfirmacionCerrarConfiguracion", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(panelConCambios)!,
"Configuracion avanzada avisa antes de descartar cambios");
var folder = Path.Combine(Path.GetTempPath(), "rutasdbus-regression-" + Guid.NewGuid().ToString("N"));
IConfiguration Config() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ValoresFabrica:Directorio"] = folder
}).Build();
ServicioValoresFabrica Service() => new(Config(), NullLogger<ServicioValoresFabrica>.Instance);
try
{
defaults.PesoTransbordoRankingMinutos = 9;
defaults.FechaSimulada = null;
defaults.HoraSimulada = null;
Directory.CreateDirectory(folder);
var file = Path.Combine(folder, "configuracion-fabrica.json");
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(defaults));
var loaded = await Service().CargarAsync();
Check(loaded?.PesoTransbordoRankingMinutos == 9, "Valores compartidos entre instancias");
Check(loaded is { FechaSimulada: null, HoraSimulada: null }, "Valores antiguos sin fecha y hora siguen siendo validos");
var guardadaConFecha = defaults.Clonar();
guardadaConFecha.FechaSimulada = "2026-09-16";
guardadaConFecha.HoraSimulada = "13:40:00";
Check(await Service().GuardarAsync(guardadaConFecha), "Los valores de fabrica permiten guardar fecha y hora");
var cargadaConFecha = await Service().CargarAsync();
Check(
cargadaConFecha is { FechaSimulada: "2026-09-16", HoraSimulada: "13:40:00" },
"Los valores de fabrica recuperan la fecha y hora guardadas");
var pagina = new PlanificadorRutas();
typeof(PlanificadorRutas)
.GetField("_configuracionFabrica", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(pagina, cargadaConFecha);
var crearPredeterminada = typeof(PlanificadorRutas).GetMethod(
"CrearConfiguracionPredeterminadaHistorial",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var predeterminada = (ConfiguracionHistorialRuta)crearPredeterminada.Invoke(pagina, null)!;
Check(
predeterminada is { FechaSimulada: "2026-09-16", HoraSimulada: "13:40:00" },
"Restaurar conserva la fecha y hora de los valores de fabrica");
File.WriteAllText(file, "{incompleto");
Check(await Service().CargarAsync() is null, "JSON danado permite usar valores base");
}
finally
{
// Solo datos temporales creados por esta prueba.
if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true);
}
var fake = new FakeValhalla();
var planner = new PlanificadorRutasServicio(null!, null!, fake, null!, null!, null!, null!);
var walk = typeof(PlanificadorRutasServicio).GetMethod("WalkValhallaCached", BindingFlags.Instance | BindingFlags.NonPublic)!;
async Task<(double[,]? linea, double distancia, double duracion)> Walk(List<List<double[]>>? zones = null) =>
await (Task<(double[,]?, double, double)>)walk.Invoke(planner, new object?[] { 43.31, -1.99, 43.32, -1.98, zones, null })!;
List<double[]> Ring(double lon, double lat, double size) =>
new() { new[] { lon, lat }, new[] { lon + size, lat }, new[] { lon + size, lat + size },
new[] { lon, lat + size }, new[] { lon, lat } };
string Polyline6(params (double lat, double lon)[] puntos)
{
var texto = new System.Text.StringBuilder();
var latitudAnterior = 0;
var longitudAnterior = 0;
foreach (var punto in puntos)
{
Codificar((int)Math.Round(punto.lat * 1_000_000) - latitudAnterior, texto);
Codificar((int)Math.Round(punto.lon * 1_000_000) - longitudAnterior, texto);
latitudAnterior = (int)Math.Round(punto.lat * 1_000_000);
longitudAnterior = (int)Math.Round(punto.lon * 1_000_000);
}
return texto.ToString();
static void Codificar(int diferencia, System.Text.StringBuilder texto)
{
var valor = diferencia < 0 ? ~(diferencia << 1) : diferencia << 1;
while (valor >= 0x20)
{
texto.Append((char)((0x20 | (valor & 0x1f)) + 63));
valor >>= 5;
}
texto.Append((char)(valor + 63));
}
}
await Walk();
var far = new List<List<double[]>> { Ring(-2.3, 43.0, .01) };
await Walk(far);
Check(fake.Calls == 2 && fake.Zones == 1, "Toda zona activa se envia a V2 aunque este lejos de la ruta previa");
var near = new List<List<double[]>> { Ring(-1.985, 43.315, .001), Ring(-1.982, 43.317, .001) };
await Walk(near);
Check(fake.Zones == 2, "Se envian todos los poligonos");
await Walk(near);
Check(fake.Calls == 3, "Mismas zonas reutilizan cache restringida");
near[0][0][0] += .00001;
await Walk(near);
Check(fake.Calls == 4, "Mover un vertice invalida esa entrada de cache");
await Walk(new() { Ring(-2.2, 43.1, .5) });
Check(fake.Calls == 5, "Una zona que rodea la ruta no se ignora aunque sus vertices esten lejos");
fake.Fail = true;
var retry = new List<List<double[]>> { Ring(-1.984, 43.312, .0002) };
await Walk(retry);
fake.Fail = false;
await Walk(retry);
Check(fake.Calls == 7, "Un fallo temporal no queda guardado en cache de zonas");
var puntos = new List<double[]> { new[] { 43.314, -1.984 }, new[] { 43.315, -1.983 } };
await (Task<(double[,]?, double, double)>)walk.Invoke(planner,
new object?[] { 43.31, -1.99, 43.32, -1.98, near, puntos })!;
Check(fake.Zones == 2 && fake.Points == 2, "Se envian juntos todos los poligonos y puntos activos");
var retryHandler = new RetryValhallaHandler();
var retryConfig = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Caminata:Valhalla:BaseUrl"] = "http://v2.test"
}).Build();
var retryClient = new ClienteValhalla(new HttpClient(retryHandler), retryConfig);
await retryClient.ObtenerRutaAsync(
43.316344, -1.983193, 43.315282, -1.985457,
excludePolygons: new() { Ring(-1.9848, 43.3158, .0001) });
Check(retryHandler.Bodies.Count == 8,
"V2 amplia progresivamente la busqueda y prueba la alternativa puntual cuando recibe 442");
using (var primerIntento = JsonDocument.Parse(retryHandler.Bodies[0]))
using (var segundoIntento = JsonDocument.Parse(retryHandler.Bodies[1]))
using (var tercerIntento = JsonDocument.Parse(retryHandler.Bodies[2]))
using (var cuartoIntento = JsonDocument.Parse(retryHandler.Bodies[3]))
{
var primeraUbicacionInicial = primerIntento.RootElement.GetProperty("locations")[0];
var primeraUbicacionReintento = segundoIntento.RootElement.GetProperty("locations")[0];
var primeraUbicacionSegundoReintento = tercerIntento.RootElement.GetProperty("locations")[0];
var primeraUbicacionTercerReintento = cuartoIntento.RootElement.GetProperty("locations")[0];
Check(!primeraUbicacionInicial.TryGetProperty("radius", out _) &&
primeraUbicacionReintento.GetProperty("radius").GetInt32() == 50 &&
primeraUbicacionSegundoReintento.GetProperty("radius").GetInt32() == 100 &&
primeraUbicacionTercerReintento.GetProperty("radius").GetInt32() == 200,
"Los reintentos buscan una arista alternativa hasta un radio controlado de 200 metros");
}
var polygonFallbackHandler = new PolygonNoPathThenPointSuccessHandler(
Polyline6((43.31, -1.99), (43.32, -1.98)),
Polyline6((43.31, -1.99), (43.31, -1.98), (43.32, -1.98)));
var polygonFallbackClient = new ClienteValhalla(new HttpClient(polygonFallbackHandler), retryConfig);
var rutaConPoligonoConvertido = await polygonFallbackClient.ObtenerRutaAsync(
43.31, -1.99, 43.32, -1.98,
excludePolygons: new() { Ring(-1.9855, 43.3145, .001) });
Check(rutaConPoligonoConvertido.Linea is not null && polygonFallbackHandler.Bodies.Count == 6,
"Un poligono que devuelve 442 se sustituye por puntos y encuentra una alternativa");
using (var peticionConPuntos = JsonDocument.Parse(polygonFallbackHandler.Bodies[^1]))
{
Check(peticionConPuntos.RootElement.GetProperty("exclude_polygons").ValueKind == JsonValueKind.Null &&
peticionConPuntos.RootElement.GetProperty("exclude_locations").GetArrayLength() > 0,
"El fallback conserva la zona como validacion y envia a v2 las aristas afectadas");
}
var poligonoValidacion = Ring(-1.9855, 43.3145, .001);
var validationHandler = new PolygonValidationValhallaHandler(
Polyline6((43.31, -1.99), (43.32, -1.98)),
Polyline6((43.31, -1.99), (43.31, -1.98), (43.32, -1.98)));
var validationClient = new ClienteValhalla(new HttpClient(validationHandler), retryConfig);
var rutaValidada = await validationClient.ObtenerRutaAsync(
43.31, -1.99, 43.32, -1.98, excludePolygons: new() { poligonoValidacion });
Check(validationHandler.Bodies.Count == 2 && rutaValidada.Linea is not null,
"V2 recalcula una ruta que atraviesa una zona activa");
using (var peticionReforzada = JsonDocument.Parse(validationHandler.Bodies[1]))
{
Check(peticionReforzada.RootElement.GetProperty("exclude_locations").GetArrayLength() > 0,
"La ruta conflictiva refuerza la exclusion sobre las aristas atravesadas");
}
var parametrosConExclusion = new ParametrosPlanificadorRuta
{
UsarValhallaParaCaminatas = true,
PoligonosExclusionCaminata = new() { Ring(-1.985, 43.315, .001) }
};
var caminataNoResuelta = typeof(PlanificadorRutasServicio).GetMethod(
"CaminataNoResueltaConExclusiones", BindingFlags.Static | BindingFlags.NonPublic)!;
bool RechazarCaminata(ParametrosPlanificadorRuta parametros, double directa, double[,]? linea, double distancia) =>
(bool)caminataNoResuelta.Invoke(null, new object?[] { parametros, directa, linea, distancia })!;
Check(RechazarCaminata(parametrosConExclusion, 100, null, 0),
"Una caminata V2 sin ruta se descarta cuando hay exclusiones");
Check(!RechazarCaminata(new ParametrosPlanificadorRuta { UsarValhallaParaCaminatas = true }, 100, null, 0),
"Sin exclusiones se conserva el fallback historico");
Check(!RechazarCaminata(parametrosConExclusion, 100, new[,] { { 43.31, -1.99 }, { 43.32, -1.98 } }, 120),
"Una caminata V2 valida se conserva con exclusiones");
var puntoDentro = typeof(PlanificadorRutasServicio).GetMethod(
"PuntoDentroDePoligonoExclusion", BindingFlags.Static | BindingFlags.NonPublic)!;
Check((bool)puntoDentro.Invoke(null, new object?[] { 43.3155, -1.9845, parametrosConExclusion.PoligonosExclusionCaminata })!,
"Las paradas interiores a una zona se detectan antes de calcular");
var page = new PlanificadorRutas();
var flags = BindingFlags.Instance | BindingFlags.NonPublic;
var paginaRelojEnEdicion = new PlanificadorRutas();
typeof(PlanificadorRutas).GetField("_editandoConfiguracionAvanzada", flags)!
.SetValue(paginaRelojEnEdicion, true);
typeof(PlanificadorRutas).GetField("_usarHoraSimulada", flags)!
.SetValue(paginaRelojEnEdicion, true);
typeof(PlanificadorRutas).GetField("_horaAhoraTxt", flags)!
.SetValue(paginaRelojEnEdicion, "Hora aplicada");
var cambiarHoraSimulada = typeof(PlanificadorRutas).GetMethod(
"OnChangeHoraSimulada", flags)!;
await (Task)cambiarHoraSimulada.Invoke(
paginaRelojEnEdicion,
new object[]
{
new Microsoft.AspNetCore.Components.ChangeEventArgs { Value = "18:45" }
})!;
Check(
(string)typeof(PlanificadorRutas).GetField("_horaAhoraTxt", flags)!
.GetValue(paginaRelojEnEdicion)! == "Hora aplicada" &&
(TimeOnly)typeof(PlanificadorRutas).GetField("_horaSimulada", flags)!
.GetValue(paginaRelojEnEdicion)! == new TimeOnly(18, 45),
"Editar la hora deja el reloj aplicado intacto hasta guardar y recalcular");
var fabricaHistorial = (ConfiguracionHistorialRuta)baseMethod.Invoke(null, null)!;
fabricaHistorial.FactorEquilibradoAndarBus = 6;
fabricaHistorial.UsarHoraSimulada = true;
fabricaHistorial.FechaSimulada = "2026-09-16";
fabricaHistorial.HoraSimulada = "13:40:00";
typeof(PlanificadorRutas).GetField("_configuracionFabrica", flags)!.SetValue(page, fabricaHistorial);
var crearPredeterminadaHistorial = typeof(PlanificadorRutas).GetMethod(
"CrearConfiguracionPredeterminadaParaHistorial", flags)!;
var crearGuardadaHistorial = typeof(PlanificadorRutas).GetMethod(
"CrearConfiguracionGuardadaHistorial", flags)!;
var configuracionGuardada = fabricaHistorial.Clonar();
configuracionGuardada.UsarHoraSimulada = true;
configuracionGuardada.FechaSimulada = "2026-04-08";
configuracionGuardada.HoraSimulada = "08:46:17";
configuracionGuardada.FactorEquilibradoAndarBus = 99;
var registroConHora = new RegistroHistorialRuta { Configuracion = configuracionGuardada };
var predeterminadaHistorial = (ConfiguracionHistorialRuta)crearPredeterminadaHistorial.Invoke(page, null)!;
Check(predeterminadaHistorial.UsarHoraSimulada &&
predeterminadaHistorial.FechaSimulada == "2026-09-16" &&
predeterminadaHistorial.HoraSimulada == "13:40:00" &&
predeterminadaHistorial.FactorEquilibradoAndarBus == 6,
"Predeterminada usa tambien la fecha y hora de fabrica");
var guardada = (ConfiguracionHistorialRuta)crearGuardadaHistorial.Invoke(
page, new object[] { registroConHora })!;
Check(guardada.UsarHoraSimulada &&
guardada.FechaSimulada == "2026-04-08" &&
guardada.HoraSimulada == "08:46:17" &&
guardada.FactorEquilibradoAndarBus == 99,
"Guardada conserva la fecha, hora y parametros del historial");
var paginaCierreHistorial = new PlanificadorRutas();
typeof(PlanificadorRutas).GetField("_mostrarHistorialRutas", flags)!
.SetValue(paginaCierreHistorial, true);
typeof(PlanificadorRutas).GetField("_factorEquilibradoAndarBus", flags)!
.SetValue(paginaCierreHistorial, 99.0);
var alternarHistorial = typeof(PlanificadorRutas).GetMethod("ToggleHistorialPanel", flags)!;
await (Task)alternarHistorial.Invoke(paginaCierreHistorial, null)!;
Check(
!(bool)typeof(PlanificadorRutas).GetField("_mostrarHistorialRutas", flags)!
.GetValue(paginaCierreHistorial)! &&
(double)typeof(PlanificadorRutas).GetField("_factorEquilibradoAndarBus", flags)!
.GetValue(paginaCierreHistorial)! == 99.0,
"Cerrar el historial conserva la configuracion de la ruta visible");
typeof(PlanificadorRutas).GetField("_factorEquilibradoAndarBus", flags)!.SetValue(page, 6.0);
typeof(PlanificadorRutas).GetField("_factorPenalizacionDistanciaPie", flags)!.SetValue(page, 1.8);
typeof(PlanificadorRutas).GetField("_parametroPenalizacionDistanciaPieMetros", flags)!.SetValue(page, 200.0);
typeof(PlanificadorRutas).GetField("_pesoTransbordoRankingMinutos", flags)!.SetValue(page, 6.0);
typeof(PlanificadorRutas).GetField("_velocidadBusKmH", flags)!.SetValue(page, 16.0);
typeof(PlanificadorRutas).GetField("_mostrarElevacionesRuta", flags)!.SetValue(page, true);
var alternativaDesglose = new AlternativaRuta
{
AlternativaBus = new RutaBusAlternativa { DistanciaTotalBus = 6000 },
DistanciasPiePorTramoAprox = new List<double> { 100, 300 },
EsfuerzoExtraPendienteMetros = 25,
NumeroTransbordosReales = 1
};
var calcularScore = typeof(PlanificadorRutas).GetMethod("CalcularScoreEquilibradoFactor", flags)!;
var crearDesglose = typeof(PlanificadorRutas).GetMethod("CrearDesgloseScoreEquilibrado", flags)!;
var scoreCalculado = (double)calcularScore.Invoke(page, new object?[] { alternativaDesglose })!;
var desgloseCalculado = crearDesglose.Invoke(page, new object?[] { alternativaDesglose })!;
var scoreDesglosado = (double)desgloseCalculado.GetType().GetProperty("ScoreTotal")!
.GetValue(desgloseCalculado)!;
Check(Math.Abs(scoreCalculado - scoreDesglosado) < 0.0001,
"El desglose y el ranking usan exactamente el mismo score");
var formatearEtiqueta = typeof(PlanificadorRutas).GetMethod("FormatearEtiquetaAlternativaTarjeta", flags)!;
Check((string)formatearEtiqueta.Invoke(page, new object[]
{ new AlternativaRuta { Etiqueta = "Equilibrada" }, 0 })! == "Opción 1",
"Las alternativas de bus se muestran numeradas sin etiquetas de criterio");
Check((string)formatearEtiqueta.Invoke(page, new object[]
{ new AlternativaRuta { EsSoloAPie = true, Etiqueta = "Solo a pie (v2)" }, 3 })! == "Solo a pie (v2)",
"Las dos alternativas solo a pie conservan su identificacion");
var maximoExclusiones = typeof(PlanificadorRutas).GetField(
"MaximoExclusionesCaminata", BindingFlags.Static | BindingFlags.NonPublic)!;
Check((int)maximoExclusiones.GetRawConstantValue()! == 25,
"Las exclusiones quedan limitadas a 25 elementos");
typeof(PlanificadorRutas).GetField("_configuracionFabrica", flags)!.SetValue(page, defaults.Clonar());
typeof(PlanificadorRutas).GetField("_activarRangoLineas", flags)!.SetValue(page, true);
typeof(PlanificadorRutas).GetField("_lineaMin", flags)!.SetValue(page, 40);
typeof(PlanificadorRutas).GetField("_lineaMax", flags)!.SetValue(page, 40);
typeof(PlanificadorRutas).GetField("_activarRangoItinerarios", flags)!.SetValue(page, false);
typeof(PlanificadorRutas).GetField("_itinerarioMin", flags)!.SetValue(page, 7);
typeof(PlanificadorRutas).GetField("_itinerarioMax", flags)!.SetValue(page, 7);
typeof(PlanificadorRutas).GetMethod("AplicarValoresFabricaConservandoSeleccion", flags)!.Invoke(page, null);
Check(!(bool)typeof(PlanificadorRutas).GetField("_activarRangoLineas", flags)!.GetValue(page)! &&
(int)typeof(PlanificadorRutas).GetField("_lineaMin", flags)!.GetValue(page)! == defaults.LineaMin &&
(int)typeof(PlanificadorRutas).GetField("_lineaMax", flags)!.GetValue(page)! == defaults.LineaMax &&
(bool)typeof(PlanificadorRutas).GetField("_activarRangoItinerarios", flags)!.GetValue(page)! &&
(int)typeof(PlanificadorRutas).GetField("_itinerarioMin", flags)!.GetValue(page)! == defaults.ItinerarioMin &&
(int)typeof(PlanificadorRutas).GetField("_itinerarioMax", flags)!.GetValue(page)! == defaults.ItinerarioMax,
"Restaurar aplica tambien los valores de lineas e itinerarios");
var campoModoHistorial = typeof(PlanificadorRutas).GetField("_modoConfiguracionHistorial", flags)!;
var modoGuardada = Enum.Parse(campoModoHistorial.FieldType, "Guardada");
campoModoHistorial.SetValue(page, modoGuardada);
typeof(PlanificadorRutas).GetMethod("AplicarValoresFabricaConservandoSeleccion", flags)!.Invoke(page, null);
Check(Equals(campoModoHistorial.GetValue(page), modoGuardada),
"Restaurar valores conserva el modo de configuracion del historial");
var options = (List<AlternativaRuta>)typeof(PlanificadorRutas).GetField("_opcionesRuta", flags)!.GetValue(page)!;
var selected = typeof(PlanificadorRutas).GetField("_indiceAlternativaSeleccionada", flags)!;
var remember = typeof(PlanificadorRutas).GetMethod("ObtenerProveedorSoloAPieParaRecalculo", flags)!;
string? Remember(bool keep) => (string?)remember.Invoke(page, new object[] { keep });
options.Add(new() { EsSoloAPie = true, ProveedorRuta = "v1" });
options.Add(new() { EsSoloAPie = true, ProveedorRuta = "v2" });
selected.SetValue(page, 1);
Check(Remember(true) == "v2", "Se conserva la version andando seleccionada");
options.Clear();
Check(Remember(true) == "v2", "Otro cambio de punto durante el calculo conserva v2");
options.Add(new() { EsSoloAPie = false });
selected.SetValue(page, 0);
Check(Remember(true) is null, "Seleccionar bus libera la preferencia andando");
options.Clear();
Check(Remember(false) is null, "Nueva seleccion de criterio no hereda la preferencia andando");
var osmHandler = new OsmWidthHandler();
using var osmCache = new MemoryCache(new MemoryCacheOptions());
var osmConfig = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Caminata:Osm:BaseUrl"] = "https://osm.test/api/0.6"
}).Build();
var osmClient = new ClienteAtributosOsm(new HttpClient(osmHandler), osmCache, osmConfig);
var anchos = await osmClient.ObtenerAnchurasAsync([101, 102, 103]);
Check(anchos[101] == new AnchuraViaOsm("4.5", false) &&
anchos[102] == new AnchuraViaOsm("2", true) &&
!anchos.ContainsKey(103),
"La anchura exacta y la estimada se leen de las etiquetas OSM");
await osmClient.ObtenerAnchurasAsync([101, 102, 103]);
Check(osmHandler.Calls == 1, "Las anchuras OSM se reutilizan desde cache");
var formatearSuperficieSinDatos = typeof(PlanificadorRutas).GetMethod(
"FormatearSuperficieSegmento", BindingFlags.Static | BindingFlags.NonPublic)!;
var superficieSinDatos = (string)formatearSuperficieSinDatos.Invoke(null, [null])!;
Check(superficieSinDatos.Contains("Carriles: -") &&
superficieSinDatos.Contains("Anchura: -"),
"El tooltip mantiene todos los atributos cuando no hay datos de via");
Console.WriteLine("Pruebas completadas.");
sealed class FakeValhalla : IClienteValhalla
{
public Task<InformacionSueloTramo?> ObtenerInformacionSueloAsync(
double[,] linea, CancellationToken cancellationToken = default)
=> Task.FromResult<InformacionSueloTramo?>(null);
public int Calls { get; private set; }
public int Zones { get; private set; }
public int Points { get; private set; }
public bool Fail { get; set; }
public Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAsync(
double latitudDesde, double longitudDesde, double latitudHasta, double longitudHasta,
string costing = "pedestrian", List<List<double[]>>? excludePolygons = null,
List<double[]>? excludeLocations = null)
{
Calls++;
Zones = excludePolygons?.Count ?? 0;
Points = excludeLocations?.Count ?? 0;
return Task.FromResult<(double[,]?, double, double)>(Fail ? (null, 0, 0) :
(new[,] { { latitudDesde, longitudDesde }, { latitudHasta, longitudHasta } }, 100, 100));
}
}
sealed class FakeArchivoImportacionDbus(ArchivoImportacionDbus archivo) : IServicioArchivoImportacionDbus
{
public Task<ArchivoImportacionDbus> ObtenerAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(archivo);
public Task<IReadOnlyList<ModeloHorarioDbusInfo>> ListarModelosAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<ModeloHorarioDbusInfo>>(Array.Empty<ModeloHorarioDbusInfo>());
public Task<ModeloHorarioDbusInfo?> ObtenerModeloActivoAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult<ModeloHorarioDbusInfo?>(null);
public Task SeleccionarModeloAsync(string idModelo, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task<ModeloHorarioDbusInfo> GuardarModeloSubidoAsync(
string nombreArchivo,
Stream contenido,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
sealed class OsmWidthHandler : HttpMessageHandler
{
public int Calls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Calls++;
const string contenido = """
{
"elements": [
{ "type": "way", "id": 101, "tags": { "width": "4.5" } },
{ "type": "way", "id": 102, "tags": { "est_width": "2" } },
{ "type": "way", "id": 103, "tags": { "highway": "residential" } }
]
}
""";
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(contenido, System.Text.Encoding.UTF8, "application/json")
});
}
}
sealed class RetryValhallaHandler : HttpMessageHandler
{
public List<string> Bodies { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest)
{
Content = new StringContent(
"{\"error_code\":442,\"error\":\"No path could be found for input\"}",
System.Text.Encoding.UTF8,
"application/json")
};
}
}
sealed class PolygonValidationValhallaHandler(string primeraForma, string segundaForma) : HttpMessageHandler
{
public List<string> Bodies { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
var forma = Bodies.Count == 1 ? primeraForma : segundaForma;
var contenido = JsonSerializer.Serialize(new
{
trip = new
{
units = "kilometers",
summary = new { length = 1.0, time = 600.0 },
legs = new[] { new { shape = forma } }
}
});
return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(contenido, System.Text.Encoding.UTF8, "application/json")
};
}
}
sealed class PolygonNoPathThenPointSuccessHandler(string formaConflictiva, string formaAlternativa)
: HttpMessageHandler
{
public List<string> Bodies { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var cuerpo = await request.Content!.ReadAsStringAsync(cancellationToken);
Bodies.Add(cuerpo);
using var documento = JsonDocument.Parse(cuerpo);
var raiz = documento.RootElement;
var tienePoligonos = raiz.TryGetProperty("exclude_polygons", out var poligonos) &&
poligonos.ValueKind == JsonValueKind.Array &&
poligonos.GetArrayLength() > 0;
if (tienePoligonos)
{
return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest)
{
Content = new StringContent(
"{\"error_code\":442,\"error\":\"No path could be found for input\"}",
System.Text.Encoding.UTF8,
"application/json")
};
}
var tienePuntos = raiz.TryGetProperty("exclude_locations", out var puntos) &&
puntos.ValueKind == JsonValueKind.Array &&
puntos.GetArrayLength() > 0;
var contenido = JsonSerializer.Serialize(new
{
trip = new
{
units = "kilometers",
summary = new { length = 1.0, time = 600.0 },
legs = new[] { new { shape = tienePuntos ? formaAlternativa : formaConflictiva } }
}
});
return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(contenido, System.Text.Encoding.UTF8, "application/json")
};
}
}