diff --git a/CLAUDE.md b/CLAUDE.md index 060ea5e..9de393c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,12 +128,18 @@ disco di default `X:\`, hardcoded in `Form1.RadiceDiscoDefault`). ## Script standalone (`scripts/`) `scripts/` contiene C# file-based apps (.NET 10, `dotnet run scripts/Nome.cs -- arg1 arg2`), -senza csproj — dipendenze NuGet extra si aggiungono con `#:package Nome@versione` in cima -al file. `ExtractCreoStrings.cs` estrae la BOM di un `.asm` risolvendo ricorsivamente i -sotto-assiemi contro `cad_index.db` (stessa logica di `Form1.AssiemeCompleto`, solo codici -numerici Pro-E); `BomViaDb.cs` fa lo stesso ma con la tokenizzazione generica validata contro -il DB, usata per intercettare anche i nomi alfabetici delle parti commerciali (vedi -`docs/formato-asm.md`); `Analyze.cs` dumpa il contesto testo/hex attorno a un pattern in un +senza csproj proprio — dipendenze NuGet extra si aggiungono con `#:package Nome@versione` in +cima al file. `ExtractCreoStrings.cs` e `BomViaDb.cs` risolvono ricorsivamente l'albero +componenti di un `.asm` contro `cad_index.db` da riga di comando (stessa logica di +`Form1.RisolviAlberoComponenti`/`AssiemeCompleto`): entrambi referenziano +`PrepStep/PrepStep.csproj` via `#:project` (più `#:property TargetFramework=net10.0-windows`, +richiesto perché il progetto principale è WinForms) invece di duplicare l'euristica di +estrazione/placement, che vive solo in `PrepStep/AssemblyComponentScanner.cs` — un'unica +fonte di verità condivisa da GUI e script, vedi `docs/formato-asm.md`. Le due varianti restano +distinte solo nel candidato passato a `EstraiCodiciReferenziati`: `ExtractCreoStrings.cs` non +passa `codiciNoti` (riconosce solo i codici numerici Pro-E, dalla forma, senza bisogno del +DB); `BomViaDb.cs` lo passa, per intercettare anche i nomi alfabetici delle parti commerciali +validati contro l'indice. `Analyze.cs` dumpa il contesto testo/hex attorno a un pattern in un file binario, per investigare a mano nuovi falsi positivi prima di toccare la regex; `Query.cs` esegue una query SQL ad-hoc contro `cad_index.db` da riga di comando. diff --git a/PrepStep.Tests/AssemblyComponentScannerTests.cs b/PrepStep.Tests/AssemblyComponentScannerTests.cs index 434eab8..3856cb3 100644 --- a/PrepStep.Tests/AssemblyComponentScannerTests.cs +++ b/PrepStep.Tests/AssemblyComponentScannerTests.cs @@ -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)); } diff --git a/PrepStep.Tests/IndexerScanTests.cs b/PrepStep.Tests/IndexerScanTests.cs new file mode 100644 index 0000000..44c23f1 --- /dev/null +++ b/PrepStep.Tests/IndexerScanTests.cs @@ -0,0 +1,169 @@ +using Microsoft.Data.Sqlite; +using PrepStep; + +namespace PrepStep.Tests; + +/// 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). +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); + } + + /// 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. + private sealed class ProgressoSincrono : IProgress + { + 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); + } +} diff --git a/PrepStep.Tests/PipelineEndToEndTests.cs b/PrepStep.Tests/PipelineEndToEndTests.cs new file mode 100644 index 0000000..89d1bdb --- /dev/null +++ b/PrepStep.Tests/PipelineEndToEndTests.cs @@ -0,0 +1,156 @@ +using Microsoft.Data.Sqlite; +using PrepStep; + +namespace PrepStep.Tests; + +/// 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. +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); + } + + /// 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. + private static (SortedSet Trovati, List NonTrovati) RisolviAlbero( + string asmIniziale, Dictionary> codiciNoti) + { + string codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0]; + string codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio); + var trovati = new SortedSet(StringComparer.OrdinalIgnoreCase); + var nonTrovati = new List(); + var visti = new HashSet(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice }; + var coda = new Queue(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> 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>(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(); + 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(); + 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(); + 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); + } +} diff --git a/PrepStep/AssemblyComponentScanner.cs b/PrepStep/AssemblyComponentScanner.cs index d56378d..b35f4be 100644 --- a/PrepStep/AssemblyComponentScanner.cs +++ b/PrepStep/AssemblyComponentScanner.cs @@ -70,7 +70,11 @@ namespace PrepStep; /// silenzio come "nudo senza placement" - lì verrà comunque scartato come "non in indice", /// ma in modo tracciabile. /// -static class AssemblyComponentScanner +// Pubblica (non solo internal) perché è anche l'unica fonte di verità per l'euristica di +// estrazione, referenziata via "#:project" dagli script standalone (scripts/BomViaDb.cs, +// scripts/ExtractCreoStrings.cs) invece che duplicata al loro interno - vedi +// docs/formato-asm.md, sezione sulla trappola del "codice duplicato in tre punti". +public static class AssemblyComponentScanner { private static readonly Regex CodiceNumericoRegex = new( @"(?` → `CUSCI_DGBB7` - comportamento +corretto, non una discrepanza). + ## Strumento di analisi manuale [`scripts/Analyze.cs`](../scripts/Analyze.cs) stampa il contesto testuale ed diff --git a/scripts/BomViaDb.cs b/scripts/BomViaDb.cs index 37213a1..d64034a 100644 --- a/scripts/BomViaDb.cs +++ b/scripts/BomViaDb.cs @@ -1,17 +1,16 @@ +#:project ../PrepStep/PrepStep.csproj +#:property TargetFramework=net10.0-windows #:package Microsoft.Data.Sqlite@10.0.12 // dotnet run scripts/BomViaDb.cs -- [cad_index.db] [output.txt] // -// PROVA: a differenza di ExtractCreoStrings.cs (che restringe i candidati a un solo -// pattern di codice, tarato sui codici numerici Pro-E), qui l'estrazione è generica - -// qualunque run di lettere/cifre/underscore/trattino - e il DB decide se è un codice -// vero, così si intercettano anche i nomi alfabetici delle parti commerciali scaricate -// come STEP (es. "ANELLO_ESTERNO-1-SOLID1"). L'essere nel DB da solo non basta però: -// codici corti o generici (es. "907", "NONE") comparirebbero per puro rumore binario, -// quindi si richiede comunque il placement esplicito nel file, come in -// ExtractCreoStrings.cs. Stessa espansione ricorsiva sui sotto-assiemi. -using System.Text; -using System.Text.RegularExpressions; +// Stessa risoluzione ricorsiva dell'albero componenti di Form1.RisolviAlberoComponenti +// nella GUI, da riga di comando: usa direttamente PrepStep.AssemblyComponentScanner (via +// "#:project", niente ProjectReference separato) invece di duplicare l'euristica di +// estrazione/placement. Un'unica fonte di verità - niente rischio che script e GUI +// divergano silenziosamente, come accaduto prima di questa riscrittura (vedi +// docs/formato-asm.md). using Microsoft.Data.Sqlite; +using PrepStep; if (args.Length < 1) { @@ -23,20 +22,12 @@ string asmIniziale = args[0]; string dbPath = args.Length > 1 ? args[1] : "cad_index.db"; string outPath = args.Length > 2 ? args[2] : "BOM.txt"; -// Oltre il 90% dei codici in indice sono numerici Pro-E, ma una minoranza (parti -// commerciali scaricate come STEP, es. "ANELLO_ESTERNO-1-SOLID1") ha nomi alfabetici -// con trattini/underscore. Niente forma di codice ipotizzata a mano: si tokenizza in -// modo generico (run di lettere/cifre/underscore/trattino) e si lascia al DB decidere -// cos'è un codice vero - stesso principio già usato per il filtro, esteso anche -// all'estrazione dei candidati. Cattura anche "UNI6592"/"CUSCI_ACBB7" dentro -// "" gratis: '<' e '>' non fanno parte del charset e delimitano il token. -var codiceRegex = new Regex(@"[A-Za-z0-9_-]{3,}", RegexOptions.Compiled); - using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); connection.Open(); // Tutti i codici prt/asm noti all'indice, caricati una volta sola: il lookup per ogni -// candidato estratto dal binario diventa O(1) invece di una query per file. +// candidato estratto dal binario diventa O(1) invece di una query per file, e serve anche +// a EstraiCodiciReferenziati per validare i token generici delle parti commerciali. var codiciNoti = new Dictionary>(StringComparer.OrdinalIgnoreCase); using (var command = connection.CreateCommand()) { @@ -54,51 +45,31 @@ using (var command = connection.CreateCommand()) } Console.WriteLine($"Codici prt/asm noti nell'indice: {codiciNoti.Count}"); -// Stesso controllo di placement di ExtractCreoStrings.cs/AssemblyComponentScanner.cs: -// la sola presenza del codice nel DB non basta, perché codici corti o generici -// (es. "907", "NONE", "start_part") possono comparire per puro rumore binario. Un -// componente vero è sempre referenziato in una di queste forme (vedi -// docs/formato-asm.md): "Componente ID N (...)"/"Component id N (...)", -// il codice ripetuto due volte separato da un byte NUL, il campo -// "family_name\0\0", o il codice seguito da un bracket "". -bool HaPlacement(string testo, string codice) => - Regex.IsMatch(testo, $@"Component(?:e)? id \d+ \([^()]*{Regex.Escape(codice)}[^()]*\)", RegexOptions.IgnoreCase) - || testo.Contains(codice + "\0" + codice + "\0") - || testo.Contains("family_name\0" + codice + "\0") - || testo.Contains(codice + "<"); - var codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0]; -// Stessa esclusione di ExtractCreoStrings.cs: un assieme "_D..." referenzia sempre al -// suo interno anche l'assieme generale a cui appartiene (riferimento fantasma verso -// l'alto), da escludere subito o si risale tutto l'albero del macchinario. -var codiceRadice = codiceProprio.Split('_')[0]; +var codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio); var componentiTrovati = new SortedSet(StringComparer.OrdinalIgnoreCase); -var scartatiNonInIndice = new SortedSet(StringComparer.OrdinalIgnoreCase); -var scartatiSenzaPlacement = new SortedSet(StringComparer.OrdinalIgnoreCase); +var nonTrovati = new List(); var codiciVisti = new HashSet(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice }; var daScansionare = new Queue(new[] { asmIniziale }); while (daScansionare.Count > 0) { var asm = daScansionare.Dequeue(); - var testo = Encoding.Latin1.GetString(File.ReadAllBytes(asm)); - var candidati = codiceRegex.Matches(testo).Select(m => m.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var candidato in candidati) + foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm, codiciNoti.Keys)) { - if (!codiciVisti.Add(candidato)) + // Solo i codici già confermati da EstraiCodiciReferenziati arrivano qui: nessun + // rischio del bug "primo fallimento blocca per sempre" (vedi docs/formato-asm.md), + // perché il placement è già stato verificato per questo file specifico al suo + // interno, da zero, indipendentemente da cosa è successo negli altri file. + if (!codiciVisti.Add(codice)) continue; - if (!codiciNoti.TryGetValue(candidato, out var componenti)) + if (!codiciNoti.TryGetValue(codice, out var componenti)) { - scartatiNonInIndice.Add(candidato); + nonTrovati.Add(codice); continue; } - if (!HaPlacement(testo, candidato)) - { - scartatiSenzaPlacement.Add(candidato); - continue; - } - componentiTrovati.Add(candidato); + componentiTrovati.Add(codice); foreach (var (tipo, percorso) in componenti) if (tipo == "asm") daScansionare.Enqueue(percorso); @@ -107,7 +78,6 @@ while (daScansionare.Count > 0) File.WriteAllLines(outPath, componentiTrovati); Console.WriteLine($"{componentiTrovati.Count} componenti scritti in {outPath}"); -Console.WriteLine($"Candidati nell'indice ma senza placement (scartati, {scartatiSenzaPlacement.Count}): {string.Join(", ", scartatiSenzaPlacement.Take(30))}" - + (scartatiSenzaPlacement.Count > 30 ? ", ..." : "")); -Console.WriteLine($"Candidati non nell'indice (rumore binario): {scartatiNonInIndice.Count}"); +Console.WriteLine($"Candidati non nell'indice (scartati, {nonTrovati.Count}): {string.Join(", ", nonTrovati.Take(30))}" + + (nonTrovati.Count > 30 ? ", ..." : "")); return 0; diff --git a/scripts/ExtractCreoStrings.cs b/scripts/ExtractCreoStrings.cs index f1e9980..9fd78d9 100644 --- a/scripts/ExtractCreoStrings.cs +++ b/scripts/ExtractCreoStrings.cs @@ -1,15 +1,15 @@ +#:project ../PrepStep/PrepStep.csproj +#:property TargetFramework=net10.0-windows #:package Microsoft.Data.Sqlite@10.0.12 // dotnet run scripts/ExtractCreoStrings.cs -- [cad_index.db] [output.txt] // -// Estrae dal file .asm i codici dei componenti referenziati, espandendo ricorsivamente -// ogni sotto-assieme (.asm) trovato nell'indice, finché non restano solo PRT (foglie) o -// codici non presenti nell'indice. Stessa euristica di PrepStep/AssemblyComponentScanner.cs: -// i codici veri compaiono più volte nel file, il rumore binario quasi sempre una sola volta; -// gli ID interni "ModelView#" sono esclusi via lookbehind - vedi scripts/Analyze.cs. -// Ogni codice trovato viene validato contro l'indice cad_index.db. -using System.Text; -using System.Text.RegularExpressions; +// Come scripts/BomViaDb.cs, ma senza passare "codiciNoti" a EstraiCodiciReferenziati: si +// riconoscono solo i codici numerici Pro-E (che si validano dalla forma, senza bisogno del +// DB - vedi PrepStep/AssemblyComponentScanner.cs), non le parti commerciali con nome +// alfabetico. Prova più semplice/mirata rispetto a BomViaDb.cs, utile quando serve solo +// individuare/segnalare codici numerici mancanti dall'indice. using Microsoft.Data.Sqlite; +using PrepStep; if (args.Length < 1) { @@ -21,114 +21,29 @@ string asmIniziale = args[0]; string dbPath = args.Length > 1 ? args[1] : "cad_index.db"; string outPath = args.Length > 2 ? args[2] : "BOM.txt"; -// Seconda alternativa: componenti normalizzati definiti come family table in Creo (viti/ -// dadi/rosette UNI, ma anche cuscinetti CUSCI_...). Compaiono come "Componente ID N -// (R12--_-_U6592.PRT)": "R12--_-_U6592" è il nome della singola istanza (riga -// della family table, non un file separato su disco), il file reale indicizzato è il -// generico "uni6592.prt."/"cusci_acbb7.prt." - va quindi estratto solo -// il contenuto tra "<" e ">". -// Terza alternativa: varianti dimensionali con suffisso puramente numerico (es. cinghie -// "2702220182_8188", dove "8188" è una misura, non una lettera di revisione/sotto-assieme -// come "_C01"/"_D100") - la lettera dopo "_" è quindi opzionale, non obbligatoria. La -// lettera di revisione e il suffisso "_" possono anche comparire insieme sullo stesso -// codice (es. "9284200366A_C01", componente di un sotto-assieme già di per sé revisionato). -var codiceRegex = new Regex( - @"(?)", - RegexOptions.Compiled); -const int occorrenzeMinime = 2; - -HashSet EstraiCodiciReferenziati(string percorsoAsm) -{ - var testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm)); - var occorrenze = new Dictionary(); - foreach (Match m in codiceRegex.Matches(testo)) - occorrenze[m.Value] = occorrenze.GetValueOrDefault(m.Value) + 1; - var candidati = occorrenze.Where(kv => kv.Value >= occorrenzeMinime).Select(kv => kv.Key).ToHashSet(); - - // Ripetersi >=2 volte nel file non basta per un codice "nudo" (senza suffisso "_..."): - // riferimenti al proprio assieme padre (nome sessione/file) o a codici superati/ - // rinominati (caso "fantasma", docs/formato-asm.md) ripetono comunque il codice più - // volte pur non essendo mai un componente vero. Per questi si richiede un vero - // placement, in una delle quattro forme osservate nel binario: "Componente ID N - // (...)"/"Component id N (...)" (la stringa cambia con la - // localizzazione Creo del file); il codice ripetuto due volte di seguito separato da - // un singolo byte NUL ("\0\0", osservato per componenti di family - // table non-UNI, es. cuscinetti "CUSCI_..."); il campo "family_name\0\0" - // (osservato una sola volta per file, su un sotto-assieme referenziato come family - // table generica invece che con un placement testuale esplicito); oppure il codice - // seguito immediatamente da un bracket "" - l'istanza di una family - // table non-UNI vista nel nome (es. "2510060303", un cuscinetto): il - // codice dell'istanza non è mai indicizzato a sé (solo il generico tra "<" e ">" lo è, - // vedi sotto), ma questo segnale lo fa comunque sopravvivere fino alla ricerca nel DB - // invece di scomparire in silenzio come "nudo senza placement" - lì verrà comunque - // scartato come "non in indice", ma in modo tracciabile. Non si applica alle varianti - // con suffisso "_..." né alle istanze family table UNI: in alcuni file il placement - // riporta solo il nome istanza/generico senza il dettaglio del suffisso o del bracket - // "<...>", che vive altrove nei metadati - lì l'occorrenza ripetuta resta l'unico - // segnale disponibile. - bool HaPlacement(string codice) => - Regex.IsMatch(testo, $@"Component(?:e)? id \d+ \([^()]*{Regex.Escape(codice)}[^()]*\)", RegexOptions.IgnoreCase) - || testo.Contains(codice + "\0" + codice + "\0") - || testo.Contains("family_name\0" + codice + "\0") - || testo.Contains(codice + "<"); - - candidati.RemoveWhere(c => Regex.IsMatch(c, @"^\d{8,12}[A-Za-z]{0,2}$") && !HaPlacement(c)); - return candidati; -} - using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); connection.Open(); -// "files_ultima_versione" è una VIEW con ROW_NUMBER() OVER (...), che SQLite non -// materializza: ogni query la ricalcola sull'intera tabella "files" (~230ms anche per un -// solo codice, su ~300k righe). Batchare tutti i codici trovati in un file in un'unica -// query paga quel costo una volta per file invece che una volta per codice - decisivo -// sugli assiemi con centinaia di sotto-componenti referenziati. -Dictionary> CercaComponenti(IReadOnlyCollection codici) +var codiciNoti = new Dictionary>(StringComparer.OrdinalIgnoreCase); +using (var command = connection.CreateCommand()) { - var risultato = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var codice in codici) - risultato[codice] = new List<(string, string)>(); - if (codici.Count == 0) - return risultato; - - using var command = connection.CreateCommand(); - var nomiParametri = codici.Select((_, i) => $"@c{i}").ToList(); - // Lo stesso codice+tipo può esistere in più cartelle radice (duplicati/copie storiche - // sulla share, es. un generico family table ripetuto in 5 progetti scollegati): ordinato - // per data_modifica DESC, si tiene solo il più recente per codice+tipo (stessa regola di - // Form1.RisolviAlberoComponenti, vedi docs/formato-asm.md). command.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione " - + $"WHERE LOWER(codice) IN ({string.Join(",", nomiParametri)}) AND LOWER(tipo_file) IN ('prt', 'asm') " - + "ORDER BY codice, tipo_file, data_modifica DESC"; - int i = 0; - foreach (var codice in codici) - command.Parameters.AddWithValue(nomiParametri[i++], codice.ToLowerInvariant()); - - var tipiVisti = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + "WHERE LOWER(tipo_file) IN ('prt', 'asm') ORDER BY codice, tipo_file, data_modifica DESC"; using var reader = command.ExecuteReader(); while (reader.Read()) { - string codiceDb = reader.GetString(0); + string codice = reader.GetString(0); string tipo = reader.GetString(1).ToLowerInvariant(); - var visti = tipiVisti.TryGetValue(codiceDb, out var v) ? v : tipiVisti[codiceDb] = new HashSet(); - if (visti.Add(tipo)) - risultato[codiceDb].Add((tipo, reader.GetString(2))); + var lista = codiciNoti.TryGetValue(codice, out var l) ? l : codiciNoti[codice] = new(); + if (!lista.Any(x => x.Tipo == tipo)) + lista.Add((tipo, reader.GetString(2))); } - return risultato; } +Console.WriteLine($"Codici prt/asm noti nell'indice: {codiciNoti.Count}"); -// Espansione ricorsiva: ogni sotto-assieme (.asm) trovato viene a sua volta letto e i suoi -// componenti aggiunti alla coda, finché non restano solo PRT (foglie) o codici non presenti -// nell'indice - stessa logica di Form1.RisolviAlberoComponenti. var codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0]; -// Un assieme con suffisso "_D..." referenzia sempre al suo interno anche l'assieme -// generale (codice nudo, senza suffisso) a cui appartiene - riferimento fantasma verso -// l'alto, non un componente vero (vedi docs/formato-asm.md). Va escluso subito: se -// seguito, riporta dentro l'intero albero del macchinario invece del solo sotto-assieme -// richiesto (visto su 9284208188_D600 -> 9284208188, che da solo genera 598 falsi -// positivi invece di ~90 componenti reali). -var codiceRadice = codiceProprio.Split('_')[0]; +var codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio); + var componentiTrovati = new SortedSet(StringComparer.OrdinalIgnoreCase); var nonTrovati = new List(); var codiciVisti = new HashSet(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice }; @@ -137,14 +52,11 @@ var daScansionare = new Queue(new[] { asmIniziale }); while (daScansionare.Count > 0) { var asm = daScansionare.Dequeue(); - var candidati = EstraiCodiciReferenziati(asm) - .Where(codiciVisti.Add) - .ToList(); - var trovatiPerCodice = CercaComponenti(candidati); - foreach (var codice in candidati) + foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm)) { - var componenti = trovatiPerCodice[codice]; - if (componenti.Count == 0) + if (!codiciVisti.Add(codice)) + continue; + if (!codiciNoti.TryGetValue(codice, out var componenti)) { nonTrovati.Add(codice); continue;