Fix euristica BOM, unifica script/GUI su un'unica fonte, aggiungi test integrazione/E2E
Due bug reali trovati confrontando l'estrazione BOM con la BOM ufficiale Creo: un componente scartato per placement fallito in un file veniva bloccato per sempre anche quando il file giusto aveva un vero placement (perdendo interi sotto-alberi come 9256510047), e i codici con suffisso (_C01 ecc.) richiedevano sempre placement esplicito invece dell'eccezione per occorrenze ripetute già presente nella GUI. Entrambi corretti. AssemblyComponentScanner reso pubblico e scripts/BomViaDb.cs, ExtractCreoStrings.cs riscritti per referenziarlo via "#:project" invece di duplicare l'euristica: un'unica fonte di verità condivisa da GUI e script standalone. Aggiunti test di integrazione per Indexer.ScanAsync (mai coperto oltre al parsing del nome file) e test end-to-end che ricostruiscono l'intera pipeline disco -> indice -> risoluzione albero componenti senza toccare la UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
353a1028d9
commit
5c9661eb12
8 files changed
+514
-171
No files matched your search
@@ -148,4 +148,54 @@ public class AssemblyComponentScannerTests
|
||||
}
|
||||
finally { File.Delete(percorso); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Riconosce_codice_con_suffisso_anche_senza_dettaglio_nel_placement()
|
||||
{
|
||||
// Regressione: caso reale 9284218024b.asm -> 2546220101_C01.._C04. Il placement
|
||||
// riporta solo il codice padre senza suffisso ("Componente ID N (2546220101.PRT)"),
|
||||
// il dettaglio "_C01" compare solo altrove come stringa ripetuta, mai dentro un
|
||||
// placement esplicito. Un codice nudo nella stessa situazione andrebbe scartato
|
||||
// (vedi Scarta_codice_ripetuto_senza_un_vero_placement), ma un codice con suffisso
|
||||
// di variante è considerato "forma nota": bastano le occorrenze ripetute. Prima
|
||||
// che questa eccezione fosse portata anche in scripts/BomViaDb.cs, lo script
|
||||
// perdeva l'intero sotto-assieme (vedi docs/formato-asm.md).
|
||||
string percorso = ScriviAsmFinto(
|
||||
"Componente ID 1 (2546220101.PRT) x 2546220101_C01 y 2546220101_C01 z");
|
||||
try
|
||||
{
|
||||
var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso);
|
||||
Assert.Contains("2546220101_C01", codici);
|
||||
}
|
||||
finally { File.Delete(percorso); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_placement_e_indipendente_per_ogni_file_scansionato()
|
||||
{
|
||||
// Regressione: caso reale 9244228558.asm -> 9244236104 dentro 9244228558_C01.asm.
|
||||
// Lo stesso codice compare come riferimento fantasma (nudo, senza placement) nel
|
||||
// file dell'assieme padre, e con un vero placement nel file del sotto-assieme che
|
||||
// lo contiene realmente. La proprietà che rende sicura la BFS del chiamante
|
||||
// (Form1.RisolviAlberoComponenti, scripts/BomViaDb.cs, scripts/ExtractCreoStrings.cs)
|
||||
// è che EstraiCodiciReferenziati valuta il placement da zero per ogni file, senza
|
||||
// stato condiviso tra le chiamate: un fallimento nel file padre non deve
|
||||
// condizionare il risultato quando lo stesso codice viene ripassato per il file
|
||||
// figlio (vedi docs/formato-asm.md, "Trappola nella BFS").
|
||||
string filePadre = ScriviAsmFinto("assieme 11112222 padre 11112222 fine");
|
||||
string fileFiglio = ScriviAsmFinto("Componente ID 1 (11112222.PRT) x 11112222 y");
|
||||
try
|
||||
{
|
||||
Assert.DoesNotContain("11112222", AssemblyComponentScanner.EstraiCodiciReferenziati(filePadre));
|
||||
Assert.Contains("11112222", AssemblyComponentScanner.EstraiCodiciReferenziati(fileFiglio));
|
||||
}
|
||||
finally { File.Delete(filePadre); File.Delete(fileFiglio); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("9284208188", "9284208188")]
|
||||
[InlineData("9284208188_D600", "9284208188")]
|
||||
[InlineData("9244228558_C03-01", "9244228558")]
|
||||
public void CodiceRadice_estrae_la_parte_prima_del_primo_underscore(string codice, string radiceAttesa) =>
|
||||
Assert.Equal(radiceAttesa, AssemblyComponentScanner.CodiceRadice(codice));
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using PrepStep;
|
||||
|
||||
namespace PrepStep.Tests;
|
||||
|
||||
/// <summary>Integration test di Indexer.ScanAsync: disco temporaneo reale + DB SQLite reale,
|
||||
/// nessun dato inserito a mano (a differenza di CaseInsensitiveLookupIntegrationTests, che
|
||||
/// verifica solo le query a valle su righe già presenti). Copre la scansione vera - upsert
|
||||
/// incrementale, rilevamento nuovi/aggiornati/invariati/rimossi, esclusione cartelle "temp_" -
|
||||
/// che prima di questi test non aveva nessuna copertura (solo Indexer.ParseFilename era
|
||||
/// testato).</summary>
|
||||
public class IndexerScanTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"cadsearch_scan_{Guid.NewGuid():N}");
|
||||
private readonly string _dbPath;
|
||||
|
||||
public IndexerScanTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_dbPath = Path.Combine(_root, "indice.db");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
|
||||
private static void ScriviFile(string percorso, string contenuto = "dati")
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(percorso)!);
|
||||
File.WriteAllText(percorso, contenuto);
|
||||
}
|
||||
|
||||
/// <summary>IProgress<T> sincrono: Progress<T> marshala il report tramite
|
||||
/// SynchronizationContext.Post (o il thread pool, se assente, come nei test) - l'ultimo
|
||||
/// valore non è garantito visibile subito dopo l'await. Qui Report gira sullo stesso
|
||||
/// thread di background di Indexer.Scan, prima che Task.Run completi: l'ultimo valore è
|
||||
/// quindi affidabile non appena l'await ritorna.</summary>
|
||||
private sealed class ProgressoSincrono : IProgress<ScanProgress>
|
||||
{
|
||||
public ScanProgress? Ultimo { get; private set; }
|
||||
public void Report(ScanProgress value) => Ultimo = value;
|
||||
}
|
||||
|
||||
private async Task ScanAsync()
|
||||
{
|
||||
await Indexer.ScanAsync(_root, _dbPath, new ProgressoSincrono(), CancellationToken.None);
|
||||
}
|
||||
|
||||
private List<(string Codice, string Tipo, string? Famiglia, string CartellaRadice)> LeggiFiles()
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly");
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT codice, tipo_file, famiglia, cartella_radice FROM files";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var righe = new List<(string, string, string?, string)>();
|
||||
while (reader.Read())
|
||||
righe.Add((reader.GetString(0), reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetString(2), reader.GetString(3)));
|
||||
return righe;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Indicizza_un_file_estraendo_codice_tipo_versione_e_famiglia()
|
||||
{
|
||||
ScriviFile(Path.Combine(_root, "__Corpi", "442", "9244228573.asm.16"));
|
||||
|
||||
await ScanAsync();
|
||||
|
||||
var righe = LeggiFiles();
|
||||
Assert.Single(righe);
|
||||
Assert.Equal(("9244228573", "asm", "442", "__Corpi"), righe[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Esclude_le_cartelle_radice_che_iniziano_con_temp_()
|
||||
{
|
||||
ScriviFile(Path.Combine(_root, "temp_paris", "vecchio.asm.1"));
|
||||
ScriviFile(Path.Combine(_root, "__Corpi", "9244228573.asm.16"));
|
||||
|
||||
await ScanAsync();
|
||||
|
||||
var righe = LeggiFiles();
|
||||
Assert.Single(righe);
|
||||
Assert.Equal("__Corpi", righe[0].CartellaRadice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_non_modificato_resta_invariato_tra_due_scansioni()
|
||||
{
|
||||
string percorso = Path.Combine(_root, "__Corpi", "file.prt.1");
|
||||
ScriviFile(percorso);
|
||||
await ScanAsync();
|
||||
|
||||
var progress = new ProgressoSincrono();
|
||||
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(progress.Ultimo);
|
||||
Assert.Equal(0, progress.Ultimo!.Nuovi);
|
||||
Assert.Equal(0, progress.Ultimo.Aggiornati);
|
||||
Assert.Equal(1, progress.Ultimo.Invariati);
|
||||
Assert.Equal(0, progress.Ultimo.Rimossi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_modificato_viene_rilevato_come_aggiornato()
|
||||
{
|
||||
string percorso = Path.Combine(_root, "__Corpi", "file.prt.1");
|
||||
ScriviFile(percorso, "versione 1");
|
||||
await ScanAsync();
|
||||
|
||||
// Contenuto più lungo -> dimensione diversa, quindi rilevato come modifica anche se
|
||||
// il file system non aggiornasse data_modifica entro la risoluzione del clock.
|
||||
ScriviFile(percorso, "versione 2 con più testo dentro");
|
||||
|
||||
var progress = new ProgressoSincrono();
|
||||
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(progress.Ultimo);
|
||||
Assert.Equal(1, progress.Ultimo!.Aggiornati);
|
||||
Assert.Equal(0, progress.Ultimo.Nuovi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_cancellato_dal_disco_viene_rimosso_dal_db_alla_scansione_successiva()
|
||||
{
|
||||
string percorso = Path.Combine(_root, "__Corpi", "file.prt.1");
|
||||
ScriviFile(percorso);
|
||||
await ScanAsync();
|
||||
Assert.Single(LeggiFiles());
|
||||
|
||||
File.Delete(percorso);
|
||||
await ScanAsync();
|
||||
|
||||
Assert.Empty(LeggiFiles());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Files_ultima_versione_tiene_solo_la_versione_piu_recente_per_codice_e_tipo()
|
||||
{
|
||||
ScriviFile(Path.Combine(_root, "__Corpi", "9244228573.asm.40"));
|
||||
ScriviFile(Path.Combine(_root, "__Corpi", "9244228573.asm.41"));
|
||||
|
||||
await ScanAsync();
|
||||
|
||||
using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly");
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT versione FROM files_ultima_versione WHERE codice = '9244228573'";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
Assert.True(reader.Read());
|
||||
Assert.Equal(41L, reader.GetInt64(0));
|
||||
Assert.False(reader.Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_senza_pattern_codice_tipo_versione_va_in_tipo_altro()
|
||||
{
|
||||
ScriviFile(Path.Combine(_root, "__Corpi", "leggimi.txt"));
|
||||
|
||||
await ScanAsync();
|
||||
|
||||
var righe = LeggiFiles();
|
||||
Assert.Single(righe);
|
||||
Assert.Equal("altro", righe[0].Tipo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using PrepStep;
|
||||
|
||||
namespace PrepStep.Tests;
|
||||
|
||||
/// <summary>End-to-end: disco CAD finto -> Indexer.ScanAsync -> cad_index.db reale -> BFS di
|
||||
/// risoluzione albero componenti (stesso algoritmo di Form1.RisolviAlberoComponenti e
|
||||
/// scripts/BomViaDb.cs, che ora delegano entrambi ad AssemblyComponentScanner - vedi
|
||||
/// docs/formato-asm.md). Nessuna UI coinvolta: verifica che le tre fasi si compongano
|
||||
/// correttamente, non solo che ciascuna funzioni isolata. I contenuti .asm/.prt sono testo
|
||||
/// sintetico con gli stessi marcatori documentati per un vero file Creo ("Componente ID N
|
||||
/// (...)"), non file binari reali - sufficiente perché AssemblyComponentScanner legge il file
|
||||
/// come testo Latin1 indipendentemente dal resto del formato.</summary>
|
||||
public class PipelineEndToEndTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"cadsearch_e2e_{Guid.NewGuid():N}");
|
||||
private readonly string _dbPath;
|
||||
|
||||
public PipelineEndToEndTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_dbPath = Path.Combine(_root, "indice.db");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
|
||||
private static void ScriviFile(string percorso, string contenuto)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(percorso)!);
|
||||
File.WriteAllText(percorso, contenuto);
|
||||
}
|
||||
|
||||
/// <summary>Stesso algoritmo BFS di scripts/BomViaDb.cs: qui non è duplicazione a rischio
|
||||
/// come lo era prima della riscrittura (vedi docs/formato-asm.md) - è la prova, in un
|
||||
/// test, che comporre AssemblyComponentScanner con un semplice ciclo di accodamento dà il
|
||||
/// risultato atteso sopra un indice reale, non un mock.</summary>
|
||||
private static (SortedSet<string> Trovati, List<string> NonTrovati) RisolviAlbero(
|
||||
string asmIniziale, Dictionary<string, List<(string Tipo, string Percorso)>> codiciNoti)
|
||||
{
|
||||
string codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0];
|
||||
string codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio);
|
||||
var trovati = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var nonTrovati = new List<string>();
|
||||
var visti = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
|
||||
var coda = new Queue<string>(new[] { asmIniziale });
|
||||
|
||||
while (coda.Count > 0)
|
||||
{
|
||||
var asm = coda.Dequeue();
|
||||
foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm, codiciNoti.Keys))
|
||||
{
|
||||
if (!visti.Add(codice))
|
||||
continue;
|
||||
if (!codiciNoti.TryGetValue(codice, out var componenti))
|
||||
{
|
||||
nonTrovati.Add(codice);
|
||||
continue;
|
||||
}
|
||||
trovati.Add(codice);
|
||||
foreach (var (tipo, percorso) in componenti)
|
||||
if (tipo == "asm")
|
||||
coda.Enqueue(percorso);
|
||||
}
|
||||
}
|
||||
return (trovati, nonTrovati);
|
||||
}
|
||||
|
||||
private Dictionary<string, List<(string Tipo, string Percorso)>> CaricaCodiciNoti()
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly");
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione "
|
||||
+ "WHERE LOWER(tipo_file) IN ('prt', 'asm')";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var codiciNoti = new Dictionary<string, List<(string, string)>>(StringComparer.OrdinalIgnoreCase);
|
||||
while (reader.Read())
|
||||
{
|
||||
string codice = reader.GetString(0);
|
||||
var lista = codiciNoti.TryGetValue(codice, out var l) ? l : codiciNoti[codice] = new();
|
||||
lista.Add((reader.GetString(1), reader.GetString(2)));
|
||||
}
|
||||
return codiciNoti;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Risolve_lalbero_completo_a_partire_dal_disco_indicizzato()
|
||||
{
|
||||
// TOP -> CHILD (vero placement, 2 occorrenze) -> LEAF (vero placement, 2 occorrenze).
|
||||
// "99999999" compare una sola volta in TOP: rumore binario, va scartato anche se
|
||||
// fosse indicizzato (vedi AssemblyComponentScannerTests.Scarta_codici_con_una_sola_occorrenza).
|
||||
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1"),
|
||||
"inizio Componente ID 1 (23456789.ASM) testo Componente ID 2 (23456789.ASM) rumore 99999999 fine");
|
||||
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "23456789.asm.1"),
|
||||
"Componente ID 1 (34567890.PRT) blah Componente ID 2 (34567890.PRT) fine");
|
||||
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "34567890.prt.1"), "dati foglia");
|
||||
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "99999999.prt.1"), "mai referenziato davvero");
|
||||
|
||||
var progress = new Progress<ScanProgress>();
|
||||
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
|
||||
|
||||
var codiciNoti = CaricaCodiciNoti();
|
||||
var asmIniziale = Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1");
|
||||
var (trovati, nonTrovati) = RisolviAlbero(asmIniziale, codiciNoti);
|
||||
|
||||
Assert.Equal(new[] { "23456789", "34567890" }, trovati);
|
||||
Assert.DoesNotContain("99999999", trovati);
|
||||
Assert.Empty(nonTrovati);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Segnala_come_non_trovato_un_componente_con_placement_vero_ma_mai_indicizzato()
|
||||
{
|
||||
// "23456789" ha un vero placement in TOP ma il suo file non esiste sul disco finto
|
||||
// (mai scansionato -> non in cad_index.db): deve finire tra i "non trovati", non far
|
||||
// fallire la risoluzione dell'albero.
|
||||
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1"),
|
||||
"Componente ID 1 (23456789.ASM) testo Componente ID 2 (23456789.ASM) fine");
|
||||
|
||||
var progress = new Progress<ScanProgress>();
|
||||
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
|
||||
|
||||
var codiciNoti = CaricaCodiciNoti();
|
||||
var asmIniziale = Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1");
|
||||
var (trovati, nonTrovati) = RisolviAlbero(asmIniziale, codiciNoti);
|
||||
|
||||
Assert.Empty(trovati);
|
||||
Assert.Contains("23456789", nonTrovati);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_in_cartella_temp_non_entra_nellindice_e_quindi_non_viene_risolto()
|
||||
{
|
||||
// Stesso codice "23456789" esiste per davvero, ma solo dentro una cartella radice
|
||||
// "temp_..." - esclusa dalla scansione (vedi Indexer.cs e docs/formato-asm.md, falso
|
||||
// positivo su copie scollegate dentro temp_*). Il componente deve restare "non
|
||||
// trovato", non risolversi per sbaglio contro la copia in temp_.
|
||||
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1"),
|
||||
"Componente ID 1 (23456789.ASM) testo Componente ID 2 (23456789.ASM) fine");
|
||||
ScriviFile(Path.Combine(_root, "temp_davide", "23456789.asm.1"), "copia scollegata");
|
||||
|
||||
var progress = new Progress<ScanProgress>();
|
||||
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
|
||||
|
||||
var codiciNoti = CaricaCodiciNoti();
|
||||
var asmIniziale = Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1");
|
||||
var (trovati, nonTrovati) = RisolviAlbero(asmIniziale, codiciNoti);
|
||||
|
||||
Assert.Empty(trovati);
|
||||
Assert.Contains("23456789", nonTrovati);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user