Add wizard UI, general-assembly resolution, and test suite

Restructure Form1 into a sequential wizard (database -> import -> raw ->
recursive component resolution -> completeness/STEP report), fix
case-sensitivity bugs in codice/tipo_file lookups and a word-boundary gap
in the assembly scanner that missed _D100-style sub-assembly references,
and treat the distinta's general assembly as an ordinary CSV row (derived
from the DBP_<codice>_<timestamp> filename) instead of special-casing it
throughout. Also adds CadSearchGui.Tests (xUnit) covering the scanner,
filename parsing, and the case-insensitive DB lookup behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-16 16:25:52 +02:00
1 parent 146ce78eae
commit 52483177f0
16 files changed
+1144 -249

No files matched your search

@@ -0,0 +1,60 @@
using CadSearchGui;
namespace CadSearchGui.Tests;
public class AssemblyComponentScannerTests
{
private static string ScriviAsmFinto(string testo)
{
string percorso = Path.GetTempFileName();
File.WriteAllBytes(percorso, System.Text.Encoding.Latin1.GetBytes(testo));
return percorso;
}
[Fact]
public void Scarta_codici_con_una_sola_occorrenza()
{
// "11112222" compare 2 volte (riferimento vero), "99998888" una sola (rumore).
string percorso = ScriviAsmFinto("blah 11112222 blah 99998888 blah 11112222 fine");
try
{
var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso);
Assert.Contains("11112222", codici);
Assert.DoesNotContain("99998888", codici);
}
finally { File.Delete(percorso); }
}
[Fact]
public void Riconosce_sotto_assiemi_con_suffisso_stile_D100()
{
// Visto su 9244228573.asm: i sotto-assiemi hanno nomi come <codice>_D100/_D200/_D300.
string percorso = ScriviAsmFinto("x 9244228573_D100 y 9244228573_D100 z 9244228573_D200 9244228573_D200");
try
{
var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso);
Assert.Contains("9244228573_D100", codici);
Assert.Contains("9244228573_D200", codici);
}
finally { File.Delete(percorso); }
}
[Fact]
public void Non_estrae_nulla_da_una_sequenza_di_cifre_troppo_lunga()
{
// Una sequenza di 15 cifre di fila non contiene nessun confine di parola valido
// per un blocco di 8-12 cifre: non deve produrre falsi positivi troncati.
string percorso = ScriviAsmFinto("123456789012345 123456789012345");
try
{
var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso);
Assert.Empty(codici);
}
finally { File.Delete(percorso); }
}
[Fact]
public void File_inesistente_lancia_eccezione_IO() =>
Assert.Throws<DirectoryNotFoundException>(() =>
AssemblyComponentScanner.EstraiCodiciReferenziati(@"C:\non\esiste\pippo.asm.1"));
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CadSearchGui\CadSearchGui.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,124 @@
using CadSearchGui;
using Microsoft.Data.Sqlite;
namespace CadSearchGui.Tests;
/// <summary>Ricrea lo schema reale del DB (stessa costante SQL usata da Indexer.ScanAsync) e
/// verifica, con dati inseriti a mano, i due comportamenti che in passato hanno causato bug
/// silenziosi: il confronto case-insensitive su codice/tipo_file e la vista
/// files_ultima_versione che tiene solo l'ultima versione per cartella_radice+codice+tipo.</summary>
public class CaseInsensitiveLookupIntegrationTests : IDisposable
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"cadsearch_test_{Guid.NewGuid():N}.db");
private readonly SqliteConnection _connection;
public CaseInsensitiveLookupIntegrationTests()
{
_connection = new SqliteConnection($"Data Source={_dbPath}");
_connection.Open();
using var schemaCmd = _connection.CreateCommand();
schemaCmd.CommandText = Indexer.Schema;
schemaCmd.ExecuteNonQuery();
}
public void Dispose()
{
_connection.Dispose();
SqliteConnection.ClearPool(_connection);
File.Delete(_dbPath);
}
private void InserisciFile(string percorso, string cartella, string codice, string tipo, int versione, string dataModifica)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO files (percorso_completo, percorso_relativo, nome_file, cartella_radice,
famiglia, codice, tipo_file, versione, dimensione_byte, data_modifica, scansionato_il)
VALUES (@p, @p, @p, @cartella, NULL, @codice, @tipo, @versione, 1, @modifica, @modifica)
""";
cmd.Parameters.AddWithValue("@p", percorso);
cmd.Parameters.AddWithValue("@cartella", cartella);
cmd.Parameters.AddWithValue("@codice", codice);
cmd.Parameters.AddWithValue("@tipo", tipo);
cmd.Parameters.AddWithValue("@versione", versione);
cmd.Parameters.AddWithValue("@modifica", dataModifica);
cmd.ExecuteNonQuery();
}
/// <summary>Stessa query (LOWER su entrambi i lati) usata da Form1 per risolvere un codice
/// referenziato in un .asm: la scansione euristica del binario può restituire maiuscole
/// diverse da quelle del file reale su disco (es. "_D100" estratto vs "_d100" su X:\).</summary>
private List<(string Tipo, string Percorso)> CercaCasoInsensitive(string codiceCercato)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT tipo_file, percorso_completo FROM files_ultima_versione "
+ "WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ('prt', 'asm') "
+ "ORDER BY tipo_file, data_modifica DESC";
cmd.Parameters.AddWithValue("@codice", codiceCercato);
using var reader = cmd.ExecuteReader();
var risultati = new List<(string, string)>();
while (reader.Read())
risultati.Add((reader.GetString(0), reader.GetString(1)));
return risultati;
}
[Fact]
public void Trova_il_file_anche_se_la_maiuscola_del_codice_non_corrisponde()
{
InserisciFile(@"X:\__Corpi\442\M_B\9244228573_d100.asm.16", "__Corpi", "9244228573_d100", "asm", 16, "2018-08-22T14:51:19Z");
var risultati = CercaCasoInsensitive("9244228573_D100");
Assert.Single(risultati);
Assert.Equal("asm", risultati[0].Tipo);
}
[Fact]
public void Trova_il_file_anche_se_il_tipo_e_maiuscolo()
{
InserisciFile(@"X:\Commerciali\Elettrici\1504240807.PRT.1", "Commerciali", "1504240807", "PRT", 1, "2020-01-01T00:00:00Z");
var risultati = CercaCasoInsensitive("1504240807");
Assert.Single(risultati);
Assert.Equal("PRT", risultati[0].Tipo);
}
[Fact]
public void Tra_due_cartelle_radice_diverse_tiene_solo_la_versione_piu_recente()
{
// Stesso codice+tipo duplicato su due cartelle radice diverse (capitato con temp_paris
// vs __Corpi): la vista files_ultima_versione deve restituire il più recente per
// cartella, ma la query applicativa a valle deve poi tenerne solo uno in assoluto
// (comportamento verificato qui a livello di dati grezzi restituiti dalla vista).
InserisciFile(@"X:\temp_paris\gabbia45_modelli_copia\9244228573.asm.1", "temp_paris", "9244228573", "asm", 1, "2014-06-25T07:38:13Z");
InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.41", "__Corpi", "9244228573", "asm", 41, "2019-05-08T13:27:23Z");
var risultati = CercaCasoInsensitive("9244228573");
// La vista non deduplica tra cartelle radice diverse: la query applicativa ordina per
// data_modifica DESC e tiene solo la prima per tipo, cioè quella più recente.
Assert.Equal(2, risultati.Count);
Assert.Equal(@"X:\__Corpi\442\M_B\9244228573.asm.41", risultati[0].Percorso);
}
[Fact]
public void La_vista_ultima_versione_scarta_le_versioni_precedenti_nella_stessa_cartella()
{
InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.40", "__Corpi", "9244228573", "asm", 40, "2018-08-22T14:51:27Z");
InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.41", "__Corpi", "9244228573", "asm", 41, "2019-05-08T13:27:23Z");
var risultati = CercaCasoInsensitive("9244228573");
Assert.Single(risultati);
Assert.Contains(".asm.41", risultati[0].Percorso);
}
[Fact]
public void Nessun_risultato_per_un_codice_non_indicizzato()
{
InserisciFile(@"X:\__Corpi\442\M_B\9244228573.asm.41", "__Corpi", "9244228573", "asm", 41, "2019-05-08T13:27:23Z");
Assert.Empty(CercaCasoInsensitive("0000000000"));
}
}
@@ -0,0 +1,69 @@
using CadSearchGui;
namespace CadSearchGui.Tests;
public class DistintaBaseImporterTests
{
private const string SpreadsheetMlMinimo = """
<?xml version="1.0"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="Distinta Base" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Table>
<Row><Cell ss:Index="2"><Data>Componente</Data></Cell><Cell><Data>Descrizione</Data></Cell></Row>
<Row><Cell ss:Index="2"><Data>1234567890</Data></Cell><Cell><Data>Vite</Data></Cell></Row>
</Table>
</Worksheet>
</Workbook>
""";
private static string ScriviFileDistinta(string nomeFile, string contenuto)
{
string percorso = Path.Combine(Path.GetTempPath(), $"cadsearch_test_{Guid.NewGuid():N}", nomeFile);
Directory.CreateDirectory(Path.GetDirectoryName(percorso)!);
File.WriteAllText(percorso, contenuto);
return percorso;
}
[Fact]
public void Aggiunge_assieme_generale_dedotto_dal_nome_file_DBP()
{
string percorso = ScriviFileDistinta("DBP_9244228573_20260916144238.xls", SpreadsheetMlMinimo);
try
{
var (_, righe) = DistintaBaseImporter.ConvertiInCsv(percorso);
Assert.Contains(righe, r => r.Componente == "9244228573" && r.Descrizione == "Assieme generale");
Assert.Contains(righe, r => r.Componente == "1234567890");
}
finally { Directory.Delete(Path.GetDirectoryName(percorso)!, recursive: true); }
}
[Fact]
public void Non_duplica_lassieme_generale_se_gia_presente_tra_i_componenti()
{
// Caso limite: il codice del nome file coincide con un componente già elencato nel foglio.
string contenutoConGeneraleGiaPresente = SpreadsheetMlMinimo.Replace("1234567890", "9244228573");
string percorso = ScriviFileDistinta("DBP_9244228573_20260916144238.xls", contenutoConGeneraleGiaPresente);
try
{
var (_, righe) = DistintaBaseImporter.ConvertiInCsv(percorso);
Assert.Single(righe, r => r.Componente == "9244228573");
}
finally { Directory.Delete(Path.GetDirectoryName(percorso)!, recursive: true); }
}
[Fact]
public void Nessun_assieme_generale_aggiunto_se_il_nome_file_non_segue_il_pattern_DBP()
{
string percorso = ScriviFileDistinta("estrazione_qualsiasi.xls", SpreadsheetMlMinimo);
try
{
var (_, righe) = DistintaBaseImporter.ConvertiInCsv(percorso);
Assert.DoesNotContain(righe, r => r.Descrizione == "Assieme generale");
Assert.Single(righe);
}
finally { Directory.Delete(Path.GetDirectoryName(percorso)!, recursive: true); }
}
}
@@ -0,0 +1,31 @@
using CadSearchGui;
namespace CadSearchGui.Tests;
public class IndexerParseFilenameTests
{
[Theory]
[InlineData("9244228573.asm.41", "9244228573", "asm", 41)]
[InlineData("9244228573_D100.asm.16", "9244228573_D100", "asm", 16)]
[InlineData("1504240807.PRT.1", "1504240807", "PRT", 1)]
public void Estrae_codice_tipo_e_versione_dal_nome_file(
string nomeFile, string codiceAtteso, string tipoAtteso, int versioneAttesa)
{
var (codice, tipo, versione) = Indexer.ParseFilename(nomeFile);
Assert.Equal(codiceAtteso, codice);
Assert.Equal(tipoAtteso, tipo);
Assert.Equal(versioneAttesa, versione);
}
[Theory]
[InlineData("9244228573.edz")]
[InlineData("readme.txt")]
[InlineData("senza_estensioni")]
public void File_senza_pattern_codice_tipo_versione_finisce_in_altro(string nomeFile)
{
var (codice, tipo, versione) = Indexer.ParseFilename(nomeFile);
Assert.Equal(nomeFile, codice);
Assert.Equal("altro", tipo);
Assert.Null(versione);
}
}