fix: match filenames against both animal_id_string and animal_name
Multi-word names ("Rat A") now allow any word separator in the
filename — space, underscore, or dash — so "Rat_A_session.csv"
and "Rat-A-data.bin" both match. Single-word names and numeric IDs
are unchanged. Partial overlaps still rejected ("Rat" won't hit
"Rationale"). Tests: 5 new name-based match cases (24 total).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,15 +9,29 @@ function escapeRegex(str) {
|
||||
|
||||
/**
|
||||
* Build a regex that matches an animal's identifier in a filename.
|
||||
* Matches only when the ID is not surrounded by other alphanumeric chars,
|
||||
* so "2852" matches "rat_2852_session" but not "28520" or "X2852Y".
|
||||
* Tries both animal_id_string and animal_name (OR logic).
|
||||
*
|
||||
* Rules:
|
||||
* - Match must not be preceded or followed by an alphanumeric char,
|
||||
* so "2852" matches "rat_2852_session" but not "28520" or "X2852Y".
|
||||
* - Multi-word names ("Rat A") allow any separator (space, dash, underscore)
|
||||
* between words, so "Rat A" matches "rat_a_session" and "Rat-A-data".
|
||||
*/
|
||||
function buildAnimalRegex(animal) {
|
||||
const ids = [];
|
||||
if (animal.animal_id_string) ids.push(escapeRegex(animal.animal_id_string));
|
||||
if (animal.animal_name) ids.push(escapeRegex(animal.animal_name));
|
||||
if (ids.length === 0) return null;
|
||||
return new RegExp(`(?<![a-zA-Z0-9])(${ids.join('|')})(?![a-zA-Z0-9])`, 'i');
|
||||
const patterns = [];
|
||||
|
||||
if (animal.animal_id_string) {
|
||||
patterns.push(escapeRegex(animal.animal_id_string));
|
||||
}
|
||||
|
||||
if (animal.animal_name) {
|
||||
const words = animal.animal_name.trim().split(/\s+/).map(escapeRegex);
|
||||
// Single-word name: use as-is. Multi-word: allow space/dash/underscore between words.
|
||||
patterns.push(words.length === 1 ? words[0] : words.join('[\\s_\\-]+'));
|
||||
}
|
||||
|
||||
if (patterns.length === 0) return null;
|
||||
return new RegExp(`(?<![a-zA-Z0-9])(${patterns.join('|')})(?![a-zA-Z0-9])`, 'i');
|
||||
}
|
||||
|
||||
function classifyFile(filename) {
|
||||
|
||||
@@ -142,6 +142,55 @@ describe('filename-to-subject matching', () => {
|
||||
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── animal_name matching ───────────────────────────────────────────────────
|
||||
|
||||
it('matches by animal_name when the name appears literally in the filename', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Homer', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_Homer_session.csv`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`${DATE}_Homer_session.csv`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches multi-word animal_name with underscores as separator in filename', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches multi-word animal_name with dashes as separator in filename', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}-Rat-A-session.bin`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches by animal_name even when animal_id_string is also set (OR logic)', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: '2852' };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
// File uses name, not ID
|
||||
dropFiles([makeFile(`${DATE}_Rat_A_ephys.bin`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not match a partial name overlap (Rat should not match Rationale)', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile('Rationale_study_data.csv')]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning for subjects with no matched files', async () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
// Only drop file for 2852 — 3076 and 3077 get no match
|
||||
|
||||
Reference in New Issue
Block a user