fix(tickets): matching exact du #ref et curseur de pagination opaque/stable pour ticket_list (#20)

Épurement de la dette de ticket_list sur deux axes :

Recherche texte — matching exact du numéro/#ref via parse_issue_search_ref,
court-circuité avant load_issue, sans matching par substring numérique
(« 1 » ne remonte plus « 12 », « 123 »…).

Pagination — curseur opaque et stable anchor-based au lieu d'un offset fragile :
token v1.<base64url-no-pad-json> encodant le tri + l'ancre {number, sortKey},
reprise strictement après l'ancre. Curseur legacy / invalide / de version
inconnue / avec sort divergent rejeté par une erreur explicite « Invalid
cursor ». Le DTO cursor reste String → non-breaking côté UI.

Fichiers : infrastructure/src/issues.rs, app-tauri/src/tickets.rs,
app-tauri/Cargo.toml (+ base64 0.22). Tests : issue_store_text_filter,
ticket_list_ 7/7 (anchor-based + rejets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:20:40 +02:00
parent 83e4df7626
commit bd5d8d864a
5 changed files with 401 additions and 35 deletions

View File

@ -184,6 +184,132 @@ async fn issue_store_lists_by_index_filters_with_empty_or_and_and_semantics() {
assert_eq!(by_status_and_priority[0].title, "Beta");
}
#[tokio::test]
async fn issue_store_text_filter_matches_exact_ref_or_number_without_numeric_substring() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
store.create(&root, &issue(&root, 4, "Four")).await.unwrap();
store
.create(&root, &issue(&root, 40, "Forty"))
.await
.unwrap();
store
.create(&root, &issue(&root, 42, "Forty two"))
.await
.unwrap();
let by_ref = store
.list(
&root,
IssueListFilter {
text: Some("#42".to_owned()),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(
by_ref.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
vec![IssueRef::from_str("#42").unwrap()]
);
let by_number = store
.list(
&root,
IssueListFilter {
text: Some("42".to_owned()),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(
by_number
.iter()
.map(|row| row.issue_ref)
.collect::<Vec<_>>(),
vec![IssueRef::from_str("#42").unwrap()]
);
let by_padded_number = store
.list(
&root,
IssueListFilter {
text: Some("0042".to_owned()),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(
by_padded_number
.iter()
.map(|row| row.issue_ref)
.collect::<Vec<_>>(),
vec![IssueRef::from_str("#42").unwrap()]
);
let by_single_digit = store
.list(
&root,
IssueListFilter {
text: Some("4".to_owned()),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(
by_single_digit
.iter()
.map(|row| row.issue_ref)
.collect::<Vec<_>>(),
vec![IssueRef::from_str("#4").unwrap()]
);
}
#[tokio::test]
async fn issue_store_text_filter_keeps_classic_title_description_and_carnet_matching() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let by_title = issue(&root, 1, "Needle in title");
let by_description = issue(&root, 2, "Plain title")
.mutate(IssueActor::System, 2_000, |i| {
i.description = MarkdownDoc::new("Needle in description");
})
.unwrap();
let by_carnet = issue(&root, 3, "Other title")
.mutate(IssueActor::System, 2_000, |i| {
i.carnet = MarkdownDoc::new("Needle in carnet");
})
.unwrap();
store.create(&root, &by_title).await.unwrap();
store.create(&root, &by_description).await.unwrap();
store.create(&root, &by_carnet).await.unwrap();
let rows = store
.list(
&root,
IssueListFilter {
text: Some("needle".to_owned()),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(
rows.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
vec![
IssueRef::from_str("#1").unwrap(),
IssueRef::from_str("#2").unwrap(),
IssueRef::from_str("#3").unwrap()
]
);
}
#[tokio::test]
async fn issue_store_persists_and_filters_sprint_membership() {
let tmp = TempDir::new();