using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AssertionHelper;
using DataLayer;
using LibationSearchEngine;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Directory = System.IO.Directory;
namespace SearchEngineTests;
///
/// Lucene 3 has no numeric type, so a number field is indexed zero-padded to make a range sort correctly:
/// 600 minutes is stored as "00000600.00". Every other field is analyzed and keeps the number as written, so
/// the novel "1984" is stored as "1984". Padding every number in a query regardless of the field it was being
/// compared against made a numeric title unfindable: title:1984 searched titles for "00001984.00", and
/// a bare 1984 could only ever mean "some number field equals 1984".
///
[TestClass]
public class NumericTitleTests
{
private const string ORWELL = "B01984ORWEL";
private const string PETERS = "B00000014XX";
private const string CLARKE = "B02001SPACE";
private const string LONG = "B0LONGBOOK1";
private const string SHORT = "B0SHORTBOK1";
private const string FOURTEEN_HOURS = "B0HOUND0001";
private string indexDirectory = null!;
[TestInitialize]
public void Initialize()
{
indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(indexDirectory);
new SearchEngine(indexDirectory).CreateNewIndex(library);
}
[TestCleanup]
public void Cleanup()
{
try
{
if (Directory.Exists(indexDirectory))
Directory.Delete(indexDirectory, recursive: true);
}
catch (IOException)
{
// Windows refuses to delete a file Lucene still holds open, and a leftover temp directory is
// not worth failing a test over
}
}
private static LibraryBook book(string asin, string title, int lengthInMinutes)
{
var contributor = Contributor.GetEmpty();
var b = new Book(new AudibleProductId(asin), title, null, null, lengthInMinutes, ContentType.Product, [contributor], [contributor], "us");
return new LibraryBook(b, new DateTime(2026, 8, 15), "account");
}
///
/// Three novels whose titles are numbers, and three books that match those same numbers through a number
/// field instead, so a test cannot pass by finding the right book for the wrong reason. Both
/// LengthInMinutes and Hours are indexed, and both are covered.
///
/// The novels' own lengths avoid the collision on purpose. At 866 minutes "14" was found through its
/// fourteen-hour running time and the test passed even against the padding bug, which is what
/// is here to cover deliberately rather than by accident.
///
///
private static readonly List library =
[
book(ORWELL, "1984", lengthInMinutes: 675),
book(PETERS, "14", lengthInMinutes: 780),
book(CLARKE, "2001: A Space Odyssey", lengthInMinutes: 397),
book(LONG, "Sign of the Four", lengthInMinutes: 1984),
book(SHORT, "A Study in Scarlet", lengthInMinutes: 14),
book(FOURTEEN_HOURS, "The Hound of the Baskervilles", lengthInMinutes: 14 * 60)
];
private string[] search(string query)
=> [.. new SearchEngine(indexDirectory).Search(query).Docs.Select(d => d.ProductId)];
/// Typing a title into the search box finds the book, even when the title is a number.
[TestMethod]
[DataRow("1984", ORWELL)]
[DataRow("14", PETERS)]
[DataRow("2001", CLARKE)]
public void a_bare_number_finds_the_novel_named_after_it(string query, string expected)
=> CollectionAssert.Contains(search(query), expected);
///
/// Nothing a bare number used to find is given up for it. The number fields were all it could match
/// before, and it still matches every one of them: the default field holds both spellings of a number,
/// so searching for both is a union rather than a choice between them.
///
[TestMethod]
public void a_bare_number_adds_the_title_to_what_it_already_found()
{
//14 hours and 14 minutes both still match, and now so does the novel
search("14").Should().BeEquivalentTo([PETERS, SHORT, FOURTEEN_HOURS]);
search("1984").Should().BeEquivalentTo([ORWELL, LONG]);
}
/// Naming the field says which of them is wanted, and no answer is padded wrongly.
[TestMethod]
public void naming_the_field_picks_one_of_them()
{
search("title:1984").Should().BeEquivalentTo([ORWELL]);
search("LengthInMinutes:1984").Should().BeEquivalentTo([LONG]);
search("title:14").Should().BeEquivalentTo([PETERS]);
search("LengthInMinutes:14").Should().BeEquivalentTo([SHORT]);
search("Hours:14").Should().BeEquivalentTo([FOURTEEN_HOURS]);
}
/// A numeric title inside a phrase or beside other words is text too.
[TestMethod]
public void a_number_in_a_phrase_is_text()
{
search("title:\"2001 a space odyssey\"").Should().BeEquivalentTo([CLARKE]);
search("\"2001 a space odyssey\"").Should().BeEquivalentTo([CLARKE]);
search("title:2001 AND title:odyssey").Should().BeEquivalentTo([CLARKE]);
}
/// The padding exists for ranges, which still sort. This is what must not regress.
[TestMethod]
public void number_ranges_still_sort()
{
search("LengthInMinutes:[600 TO 900]").Should().BeEquivalentTo([ORWELL, PETERS, FOURTEEN_HOURS]);
search("LengthInMinutes:[1 to 400]").Should().BeEquivalentTo([CLARKE, SHORT]);
search("Hours:[10 TO 20]").Should().BeEquivalentTo([ORWELL, PETERS, FOURTEEN_HOURS]);
//curly braces exclude the bounds, and have to be recognised as a range the same way
search("LengthInMinutes:{675 TO 1984}").Should().BeEquivalentTo([PETERS, FOURTEEN_HOURS]);
}
///
/// A wildcard reaches a numeric title too, which it could not while the number was being padded:
/// "title:19*" went looking for "00000019.00*".
///
[TestMethod]
public void a_wildcard_works_on_a_numeric_title()
{
search("title:19*").Should().BeEquivalentTo([ORWELL]);
search("title:20*").Should().BeEquivalentTo([CLARKE]);
}
/// A bare number combines with the rest of the syntax without dragging its expansion along.
[TestMethod]
public void a_bare_number_combines_with_other_terms()
{
search("1984 AND title:sign").Should().BeEquivalentTo([LONG]);
search("-1984").Should().BeEquivalentTo([PETERS, CLARKE, SHORT, FOURTEEN_HOURS]);
}
}