Something went wrong. Try again.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984mod abyssals;mod header;mod inventory;mod shell;pub mod stockpile_multibuy;mod stockpile_search;mod stockpiles;mod tracker;mod tree;mod values;
use std::{ collections::{HashMap, HashSet}, time::Duration,};
use chrono::{DateTime, Utc};use iced::{ Element, Task, widget::{operation, text_editor},};
pub use self::{ stockpile_multibuy::parse as parse_multibuy, stockpile_search::{MultibuyMatch, MultibuyResolution, resolve_multibuy, search_item_types}, stockpiles::{ EDITOR_WINDOW_HEIGHT as STOCKPILE_EDITOR_WINDOW_HEIGHT, EDITOR_WINDOW_WIDTH as STOCKPILE_EDITOR_WINDOW_WIDTH, Editor, EditorEffect, EditorSeed, IMPORT_WINDOW_HEIGHT as STOCKPILE_IMPORT_WINDOW_HEIGHT, IMPORT_WINDOW_WIDTH as STOCKPILE_IMPORT_WINDOW_WIDTH, ImportEffect, ImportPanel, SEARCH_MIN_CHARS as STOCKPILE_SEARCH_MIN_CHARS, apply_editor, apply_import, import_window_title as stockpile_import_window_title, import_window_view as stockpile_import_view, resolve_scope_pilots, save_stockpile, view as stockpile_editor_view, window_title as stockpile_editor_window_title, },};pub(crate) use crate::ui::format::{fmt_count, fmt_isk, fmt_volume};use crate::{ features::{ shell::window_state::UiState, wallet::selection::{ClickKind, RowSelection}, }, services::location_search::LocationRef, store::{ Database, images, model::{ ENTITY_TYPE_ASSET, OwnerType, SavedAssetFilter, StatTemplate, TAG_SCOPE_ASSET, Tag, asset_query::{ ChildFilter, GeoSort, GeoTree, InventoryCursor, InventoryQuery, InventoryRow, InventoryTotals, SortColumn, SortDirection, }, normalize_tag_name, }, repo::{assets, character, infra, org}, }, sync::JobKind, ui::{ components::{ add_tag_modal::{AddTagMessage, AddTagModal}, resizable_pane::{self, PaneDrag}, }, load_epoch::LoadEpoch, },};
const INVENTORY_PAGE_SIZE: i64 = 200;
const INVENTORY_SCROLL_THRESHOLD: f32 = 0.85;
const SEARCH_DEBOUNCE_MS: u64 = 200;
const HEADER_SIDE_PADDING: f32 = 28.0;
const SIDEBAR_PANE_KEY: &str = "assets.sidebar";
const SIDEBAR_DEFAULT_WIDTH: f32 = 280.0;
const ABYSSALS_FILTER_PANE_KEY: &str = "assets.abyssals_filter";
const ABYSSALS_FILTER_DEFAULT_WIDTH: f32 = 240.0;
const ABYSSAL_SCROLL_THRESHOLD: f32 = 0.85;
const MUTAMARKET_MODULE_URL: &str = "https://mutamarket.com/modules";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub enum Category { #[default] All, Blueprint, Book, Charge, Commodity, Drone, Implant, Material, Module, Ship,}
impl Category { pub(super) const ALL: [Category; 10] = [ Category::All, Category::Ship, Category::Module, Category::Drone, Category::Charge, Category::Implant, Category::Blueprint, Category::Material, Category::Book, Category::Commodity, ];
pub(super) fn label(self) -> String { match self { Category::All => t!("assets.category.all").into_owned(), Category::Blueprint => t!("assets.category.blueprints").into_owned(), Category::Book => t!("assets.category.skill_books").into_owned(), Category::Charge => t!("assets.category.charges").into_owned(), Category::Commodity => t!("assets.category.commodities").into_owned(), Category::Drone => t!("assets.category.drones").into_owned(), Category::Implant => t!("assets.category.implants").into_owned(), Category::Material => t!("assets.category.materials").into_owned(), Category::Module => t!("assets.category.modules").into_owned(), Category::Ship => t!("assets.category.ships").into_owned(), } }
fn key(self) -> Option<&'static str> { match self { Category::All => None, Category::Blueprint => Some("blueprint"), Category::Book => Some("book"), Category::Charge => Some("charge"), Category::Commodity => Some("commodity"), Category::Drone => Some("drone"), Category::Implant => Some("implant"), Category::Material => Some("material"), Category::Module => Some("module"), Category::Ship => Some("ship"), } }
fn from_key(key: Option<&str>) -> Category { Category::ALL .into_iter() .find(|category| category.key() == key) .unwrap_or(Category::All) }}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]pub enum GeoNodeKey { Constellation(i64), Region(i64),}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub enum GeoSelection { #[default] All, Constellation(i64), Location(i64), Region(i64), System(i64),}
#[derive(Clone, Debug, Default)]pub struct Loaded { abyssals: abyssals::AbyssalsData, asset_tags: Vec<Tag>, corporations: Vec<RosterCorp>, /// Container ids on the path to a filter match, to auto-expand so nested matches are revealed. /// Empty when no filter is active. Ordered shallow-to-deep so apply seeds parents before children. expand_containers: Vec<i64>, geo_tree: GeoTree, hangar_divisions: HashMap<(i64, i64), String>, inventory: Vec<InventoryRow>, /// Pre-loaded children of each `expand_containers` entry, keyed by container `item_id`. Seeds the /// lazy children cache so the auto-expanded ancestor chain renders without a round of fetches. inventory_children: HashMap<i64, Vec<InventoryRow>>, nav: tracker::NavSeries, roster: Vec<RosterPilot>, saved_filters: Vec<SavedAssetFilter>, stockpiles: Vec<stockpiles::StockpileCard>, tag_memberships: HashMap<i64, Vec<i64>>, totals: InventoryTotals, values: values::ValueSummary,}
#[derive(Clone, Debug)]pub enum Message { AbyssalCardsReloaded(abyssals::FilteredCards), AbyssalFilterReset, /// `relative` is the 0.0–1.0 scroll fraction that drives the pagination threshold; `absolute` is /// the pixel offset stored to window the card grid. AbyssalGridScrolled { absolute: f32, relative: f32, }, AbyssalMutaMarketOpened(i64), AbyssalPageLoaded { cards: Vec<abyssals::AbyssalCard>, epoch: u64, }, AbyssalPickerToggled, AbyssalSliderEditCommitted(i64, SliderEndpoint), AbyssalSliderEditInput(String), AbyssalSliderEditStarted(i64, SliderEndpoint, f64), AbyssalSourceTypeSelected(Option<i64>), AbyssalStatMaxChanged(i64, f64), AbyssalStatMinChanged(i64, f64), // Constructed only by handler-routing tests; the real range-update arm is wired but not yet triggered from the UI. #[cfg_attr( not(test), expect( dead_code, reason = "Range-update dispatch arm is wired; awaiting the abyssal UI to emit it." ) )] AbyssalStatRangeChanged(i64, SliderEndpoint, f64), AbyssalStatTemplatesLoaded(Vec<StatTemplate>), AbyssalTypeModalClosed, AbyssalTypeModalOpened, AssetChartHovered(Option<f32>), /// A message from the shared add-tag modal, routed to the asset-tag host. Phase 4 (multi-select Edit /// Tags) reuses this same arm to drive the modal over a selection. AssetTagModal(AddTagMessage), /// The asset-tag registry and per-item membership map, reloaded after a modal write so chips update /// without a full inventory refetch. AssetTagsReloaded { memberships: HashMap<i64, Vec<i64>>, tags: Vec<Tag>, }, CategorySelected(Category), ContainerChildrenLoaded(i64, Vec<InventoryRow>), ContainerToggled(i64), DivisionToggled(i64, i64), FeaturesChanged(crate::config::FeatureFlags), FilterExamplePicked(&'static str), GeoNodeSelected(GeoSelection), GeoNodeToggled(GeoNodeKey), /// The location-tree sort toggle picked a mode. Re-sorts the cached tree in /// place and persists the choice; never reloads from the DB. LocSortSelected(GeoSort), InventoryCursorMoved(iced::Point), InventoryHelpToggled, /// Tracks the live keyboard modifiers so a left-click on a row can resolve its /// plain/range/toggle intent (mirrors the wallet ledger's modifier tracking). InventoryModifiersChanged(iced::keyboard::Modifiers), /// Dismisses the row context menu (Escape or backdrop click). InventoryMenuDismissed, InventoryPageLoaded { epoch: u64, rows: Vec<InventoryRow>, }, /// A left-click on an inventory row, applying the active modifiers to the /// multi-row selection. InventoryRowClicked(i64), /// A right-click on an inventory row: selects the row if it is not already in /// the selection, then opens the Edit Tags context menu at the cursor. InventoryRowRightPressed(i64), /// `relative` is the 0.0–1.0 scroll fraction that drives the pagination threshold; `absolute` is /// the pixel offset stored to window the virtual list. InventoryScrolled { absolute: f32, relative: f32, }, Loaded(Box<Loaded>), /// Opens the shared add-tag modal over the current multi-row selection, fanning every /// assign/unassign across all selected stacks. Triggered by the row context menu's "Edit Tags". OpenSelectionTagModal, PaneDrag(f32), PaneDragEnd, PaneDragStart(Pane), PaneSettled(&'static str, f32), PickerToggled, ReauthRequested(i64), SaveFilterCancelled, SaveFilterConfirmed, SaveFilterNameChanged(String), SaveFilterOpened, SavedFilterContextMenuClosed, SavedFilterCreated(Option<i64>, Vec<SavedAssetFilter>), SavedFilterDeleted(i64), SavedFilterRightPressed(i64), SavedFilterSelected(i64), SavedFiltersReloaded(Vec<SavedAssetFilter>), ScopeSelected(Scope), SearchChanged(String), SearchReloaded { generation: u64, loaded: Box<Loaded>, }, SearchSubmitted, SidebarCursorMoved(iced::Point), SortSelected(SortColumn), StockpileCardRightPressed(i64), StockpileContextMenuClosed, StockpileCursorMoved(iced::Point), StockpileDeleted(i64), StockpileEditStarted(i64), StockpileEditorClosed, StockpileEditorItemPicked(i64, String), StockpileEditorItemRemoved(usize), StockpileEditorItemResults(Vec<(i64, String)>), StockpileEditorItemSearchChanged(String), StockpileEditorItemTargetChanged(usize, String), StockpileEditorLocationCleared, StockpileEditorLocationPicked(LocationRef), StockpileEditorLocationResults(u64, Vec<LocationRef>), StockpileEditorLocationSearchChanged(String), StockpileEditorLocationToggled, StockpileEditorNameChanged(String), StockpileEditorPopoversClosed, StockpileEditorSaved, StockpileEditorScopeChanged(String), StockpileEditorScopeResolved(Vec<stockpiles::ScopePilot>), StockpileImportClosed, StockpileImportConfirmed, StockpileImportOpened, StockpileImportResolveRequested, StockpileImportResolved(MultibuyResolution), StockpileImportTextChanged(text_editor::Action), StockpileItemsToggled(i64), StockpileMultibuyExportClosed, StockpileMultibuyExportCopied(i64), StockpileMultibuyExportOpened(i64), StockpileMultibuyModeChanged(stockpiles::MultibuyMode), StockpileNew, StockpilesReloaded(Vec<stockpiles::StockpileCard>), SyncReloaded { generation: u64, loaded: Box<Loaded>, }, TabSelected(Tab), /// Records a persisted boolean UI flag in local state, then asks the app layer /// to write it through to `UiState`. Mirrors the wallet feature's flow. UiFlagSet(String, bool), /// Emitted after `UiFlagSet` so the app layer can persist the flag. A no-op /// inside the feature; the real write lives in the app-level assets handler /// (mirrors `wallet::Message::UiFlagPersisted`, routed in `app.rs` to /// `record_ui_flag`). The fields are read there, not within this feature. UiFlagPersisted(String, bool),}
impl Message { /// Whether handling this message can surface new image-bearing rows, so the shell should recheck for stale /// icons. Interaction-only messages (scroll, hover, filter edits) return `false` to keep the staleness scan off /// the per-frame path. pub fn loads_data(&self) -> bool { matches!( self, Message::AbyssalCardsReloaded(_) | Message::AbyssalPageLoaded { .. } | Message::ContainerChildrenLoaded(..) | Message::InventoryPageLoaded { .. } | Message::Loaded(_) | Message::SearchReloaded { .. } | Message::StockpileEditorScopeResolved(_) | Message::StockpilesReloaded(_) | Message::SyncReloaded { .. } ) }}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum Pane { AbyssalsFilter, Sidebar,}
#[derive(Clone, Debug, Eq, PartialEq)]pub struct RosterCorp { pub id: i64, pub logo: images::ImageState, pub name: String, pub ticker: String,}
#[derive(Clone, Debug, Eq, PartialEq)]pub struct RosterPilot { pub corp: String, pub granted_scopes: Option<String>, pub id: i64, pub name: String, pub portrait: images::ImageState,}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub enum Scope { #[default] All, Character(i64), Corporation(i64),}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum SliderEndpoint { Max, Min,}
#[derive(Debug)]pub struct State { abyssal_filters: abyssals::Filters, abyssal_has_more: bool, abyssal_loading: bool, abyssal_page_epoch: LoadEpoch, abyssal_picker_open: bool, abyssal_scroll_offset: f32, abyssal_slider_edit: Option<(i64, SliderEndpoint)>, abyssal_slider_edit_text: String, abyssal_source_types: Vec<abyssals::SourceTypeFilter>, abyssal_stat_templates: Vec<StatTemplate>, abyssal_total: i64, abyssals: Vec<abyssals::AbyssalCard>, abyssals_filter: PaneDrag, active: Scope, add_tag_modal: Option<AddTagModal>, add_tag_modal_name: String, asset_tags: Vec<Tag>, category: Category, chart_hover: Option<f32>, collapsed_divisions: HashSet<(i64, i64)>, corporations: Vec<RosterCorp>, dirty: bool, enabled_tabs: Vec<Tab>, expanded_containers: HashSet<i64>, features: crate::config::FeatureFlags, geo_expanded: HashSet<GeoNodeKey>, geo_selected: GeoSelection, geo_tree: GeoTree, hangar_division_names: HashMap<(i64, i64), String>, inventory: Vec<InventoryRow>, inventory_children: HashMap<i64, Vec<InventoryRow>>, inventory_cursor: Option<iced::Point>, inventory_has_more: bool, inventory_help_open: bool, inventory_loading: bool, /// The cursor anchor of the open row context menu; `None` when closed. inventory_menu: Option<iced::Point>, inventory_modifiers: iced::keyboard::Modifiers, inventory_page_epoch: LoadEpoch, inventory_scroll_offset: f32, /// Multi-row selection over the inventory, keyed on the globally-unique ESI `item_id`. inventory_selection: RowSelection<i64>, /// The item_ids the open add-tag modal applies to. One entry for a per-row open; the whole /// selection for a bulk Edit Tags open. Assign/unassign fan across every id here. selection_tag_ids: Vec<i64>, nav: tracker::NavSeries, picker_open: bool, roster: Vec<RosterPilot>, saved_filter_active: Option<i64>, saved_filter_context_menu: Option<SavedFilterContextMenu>, saved_filter_draft_name: String, saved_filter_modal_open: bool, saved_filters: Vec<SavedAssetFilter>, search: String, search_generation: u64, sidebar: PaneDrag, sidebar_cursor: Option<iced::Point>, sort: SortColumn, sort_dir: SortDirection, stockpile_context_menu: Option<StockpileContextMenu>, stockpile_cursor: Option<iced::Point>, stockpile_expanded: HashSet<i64>, stockpile_multibuy_copied: bool, stockpile_multibuy_export: Option<i64>, stockpile_multibuy_mode: stockpiles::MultibuyMode, stockpiles: Vec<stockpiles::StockpileCard>, tab: Tab, tag_memberships: HashMap<i64, Vec<i64>>, totals: InventoryTotals, /// Persisted boolean UI preferences (e.g. the location-tree sort mode), /// restored from `UiState.flags` and mirrored back on toggle. Mirrors the /// wallet feature's `ui_flags` precedent. ui_flags: std::collections::BTreeMap<String, bool>, values: values::ValueSummary,}
/// `UiState.flags` key for the location-tree sort mode. `true` selects A–Z,/// `false` (the default) selects Value, preserving today's behaviour.const LOC_SORT_ALPHA_FLAG: &str = "assets.loc_sort_alpha";
impl State { pub fn new(features: crate::config::FeatureFlags) -> Self { let enabled_tabs = enabled_tabs(&features); State { abyssals_filter: PaneDrag::new( ABYSSALS_FILTER_DEFAULT_WIDTH, crate::ui::style::spacing::layout::WINDOW_DEFAULT_WIDTH, ), active: Scope::default(), add_tag_modal: None, add_tag_modal_name: String::new(), asset_tags: Vec::new(), category: Category::default(), chart_hover: None, collapsed_divisions: HashSet::new(), corporations: Vec::new(), dirty: false, enabled_tabs: enabled_tabs.clone(), features, expanded_containers: HashSet::new(), geo_expanded: HashSet::new(), geo_selected: GeoSelection::default(), geo_tree: GeoTree::default(), hangar_division_names: HashMap::new(), inventory: Vec::new(), inventory_children: HashMap::new(), inventory_cursor: None, inventory_has_more: false, inventory_help_open: false, inventory_loading: false, inventory_menu: None, inventory_modifiers: iced::keyboard::Modifiers::default(), inventory_page_epoch: LoadEpoch::default(), inventory_scroll_offset: 0.0, inventory_selection: RowSelection::default(), selection_tag_ids: Vec::new(), picker_open: false, roster: Vec::new(), saved_filter_active: None, saved_filter_context_menu: None, saved_filter_draft_name: String::new(), saved_filter_modal_open: false, saved_filters: Vec::new(), search: String::new(), search_generation: 0, sidebar_cursor: None, sidebar: PaneDrag::new( SIDEBAR_DEFAULT_WIDTH, crate::ui::style::spacing::layout::WINDOW_DEFAULT_WIDTH, ), sort: SortColumn::Value, sort_dir: SortDirection::Descending, tab: resolve_first_tab(&enabled_tabs), tag_memberships: HashMap::new(), totals: InventoryTotals::default(), ui_flags: std::collections::BTreeMap::new(), values: values::ValueSummary::default(), nav: tracker::NavSeries::default(), stockpiles: Vec::new(), stockpile_context_menu: None, stockpile_cursor: None, stockpile_expanded: HashSet::new(), stockpile_multibuy_export: None, stockpile_multibuy_copied: false, stockpile_multibuy_mode: stockpiles::MultibuyMode::default(), abyssals: Vec::new(), abyssal_source_types: Vec::new(), abyssal_filters: abyssals::Filters::default(), abyssal_picker_open: false, abyssal_slider_edit: None, abyssal_slider_edit_text: String::new(), abyssal_stat_templates: Vec::new(), abyssal_has_more: false, abyssal_loading: false, abyssal_page_epoch: LoadEpoch::default(), abyssal_scroll_offset: 0.0, abyssal_total: 0, } }
pub fn with_restored_panes(mut self, ui: &UiState) -> Self { let host_width = ui.host_width("main", crate::ui::style::spacing::layout::WINDOW_DEFAULT_WIDTH); self.sidebar = PaneDrag::from_store(ui, SIDEBAR_PANE_KEY, SIDEBAR_DEFAULT_WIDTH, host_width); self.abyssals_filter = PaneDrag::from_store(ui, ABYSSALS_FILTER_PANE_KEY, ABYSSALS_FILTER_DEFAULT_WIDTH, host_width); self.ui_flags = ui.flags.clone(); self }
/// Generic persisted boolean accessor, mirroring the wallet feature's flow. pub fn ui_flag(&self, key: &str, default: bool) -> bool { self.ui_flags.get(key).copied().unwrap_or(default) }
/// The active location-tree sort mode, derived from the persisted flag. /// Defaults to `Value` (descending) on a fresh profile. pub(super) fn geo_sort(&self) -> GeoSort { if self.ui_flag(LOC_SORT_ALPHA_FLAG, false) { GeoSort::Alpha } else { GeoSort::Value } }
pub fn set_pane_host_width(&mut self, host_width: f32) { self.sidebar.set_host_width(host_width); self.abyssals_filter.set_host_width(host_width); }
pub fn active(&self) -> Scope { self.active }
pub fn active_tab(&self) -> Tab { self.tab }
pub fn select_tab_by_id(&mut self, id: &str) -> bool { match Tab::from_id(id) { Some(tab) => { self.tab = tab; true } None => false, } }
pub fn drain_dirty(&mut self, db: &Database) -> Option<Task<Message>> { if !self.dirty { return None; } self.dirty = false; Some(sync_reload(self, db)) }
#[cfg(test)] pub fn is_dirty(&self) -> bool { self.dirty }
// Unconditional reload flag for the kind-agnostic MCP write signal, which knows an agent wrote // something but not which job kind, so it can't go through the whitelisted mark_dirty path. pub fn force_dirty(&mut self) { self.dirty = true; }
pub fn mark_dirty(&mut self, kind: JobKind) { if kind == JobKind::AssetSync { self.dirty = true; } }
pub(super) fn enabled_tabs(&self) -> &[Tab] { &self.enabled_tabs }
pub(super) fn sync_features(&mut self, features: crate::config::FeatureFlags) { self.features = features; self.enabled_tabs = enabled_tabs(&features); if !self.enabled_tabs.contains(&self.tab) { self.tab = resolve_first_tab(&self.enabled_tabs); } }
/// The per-tab `(id, name, missing-scopes)` forbidden gate for a per-character view whose pilot /// lacks the active tab's read scopes; `None` for the combined view or an authorized pilot. pub(super) fn tab_scope_gate(&self) -> Option<(i64, &str, Vec<&'static str>)> { let Scope::Character(id) = self.active else { return None; }; let pilot = self.roster.iter().find(|pilot| pilot.id == id)?; let missing = crate::ui::components::forbidden::missing_scopes(pilot.granted_scopes.as_deref(), &self.tab.read_scopes()); if missing.is_empty() { return None; } Some((id, pilot.name.as_str(), missing)) }
pub fn stale_images(&self) -> Vec<(images::ImageKind, i64)> { self .roster .iter() .filter_map(|pilot| pilot.portrait.stale_key()) .chain(self.corporations.iter().filter_map(|corp| corp.logo.stale_key())) .chain(self.abyssals.iter().filter_map(|card| card.portrait.stale_key())) .collect() }
pub(super) fn tab(&self) -> Tab { self.tab }
pub(super) fn inventory(&self) -> &[InventoryRow] { &self.inventory }
/// The asset-tag chips assigned to one inventory stack (keyed on its ESI `item_id`), resolved from the /// loaded membership map against the asset-tag registry, in tag-position order. Empty for an untagged /// or unknown item. pub(super) fn asset_tags_for(&self, item_id: i64) -> Vec<&Tag> { let Some(tag_ids) = self.tag_memberships.get(&item_id) else { return Vec::new(); }; tag_ids .iter() .filter_map(|tag_id| self.asset_tags.iter().find(|tag| tag.id() == *tag_id)) .collect() }
/// The shared add-tag modal when open over an asset row, plus the asset-name resolver and the /// assigned/assignable tag partition for that row. Phase 4 reuses this same accessor shape (modal + /// resolver) to render the modal over a multi-row selection. pub(super) fn asset_tag_modal(&self) -> Option<&AddTagModal> { self.add_tag_modal.as_ref() }
/// The display name for the asset row the open modal targets, resolved and stored at open time so the /// modal view can borrow it for the render's lifetime. pub(super) fn asset_tag_modal_entity_name(&self) -> &str { &self.add_tag_modal_name }
/// Resolves an inventory row's display name — its custom name when set, else its type name, else a /// bare `Item <id>` fallback when the row has scrolled out of the loaded page. fn resolve_item_name(&self, item_id: i64) -> String { self .find_inventory_row(item_id) .map(|row| { row .name .as_deref() .filter(|name| !name.is_empty()) .unwrap_or(&row.type_name) .to_owned() }) .unwrap_or_else(|| t!("assets.inventory.item_fallback", id => item_id).into_owned()) }
/// The assigned/assignable partition for the open modal's targets. /// /// For a single target (per-row open) this is simply that stack's tags vs every other asset-scoped /// tag. For a bulk selection (Edit Tags) a tag counts as "assigned" only when it is on *all* selected /// stacks (the intersection), so the Current-tags chips show what the whole selection shares; every /// other tag is assignable. Adding a tag tags all selected stacks; removing untags all of them. pub(super) fn asset_tag_modal_partition(&self) -> (Vec<&Tag>, Vec<&Tag>) { if self.add_tag_modal.is_none() || self.selection_tag_ids.is_empty() { return (Vec::new(), Vec::new()); } let assigned_ids = self.common_tag_ids(); let assigned = self .asset_tags .iter() .filter(|tag| assigned_ids.contains(&tag.id())) .collect(); let assignable = self .asset_tags .iter() .filter(|tag| !assigned_ids.contains(&tag.id())) .collect(); (assigned, assignable) }
/// The tag ids shared by *every* selected stack (the intersection of their membership sets). For a /// single target this is just that stack's tags; empty when any selected stack is untagged. fn common_tag_ids(&self) -> Vec<i64> { let mut iter = self.selection_tag_ids.iter(); let Some(first) = iter.next() else { return Vec::new(); }; let mut common: Vec<i64> = self.tag_memberships.get(first).cloned().unwrap_or_default(); for item_id in iter { let theirs: &[i64] = self.tag_memberships.get(item_id).map_or(&[], Vec::as_slice); common.retain(|tag_id| theirs.contains(tag_id)); if common.is_empty() { break; } } common }
/// The currently-loaded inventory rows in display order (flattened with any expanded containers' /// children spliced inline), keyed on `item_id` — the order the selection's range/prune resolve /// against. pub(super) fn inventory_order(&self) -> Vec<i64> { let mut order = Vec::with_capacity(self.inventory.len()); for row in &self.inventory { push_inventory_order(self, &mut order, row); } order }
pub(super) fn inventory_row_selected(&self, item_id: i64) -> bool { self.inventory_selection.contains(item_id) }
pub(super) fn inventory_selection_count(&self) -> usize { self.inventory_selection.len() }
pub(super) fn inventory_menu(&self) -> Option<iced::Point> { self.inventory_menu }
/// The summed value and volume of the selected rows, for the selection summary slot. Walks the loaded /// rows (parents + expanded children) and sums those in the selection. pub(super) fn inventory_selection_totals(&self) -> (f64, f64) { let mut value = 0.0; let mut volume = 0.0; for row in self.inventory.iter().chain(self.inventory_children.values().flatten()) { if self.inventory_selection.contains(row.item_id) { value += row.value; volume += row.row_volume; } } (value, volume) }
fn find_inventory_row(&self, item_id: i64) -> Option<&InventoryRow> { self .inventory .iter() .chain(self.inventory_children.values().flatten()) .find(|row| row.item_id == item_id) }
pub(super) fn inventory_total(&self) -> i64 { self.totals.items }
pub(super) fn inventory_scroll_offset(&self) -> f32 { self.inventory_scroll_offset }
pub(super) fn inventory_sort(&self) -> (SortColumn, SortDirection) { (self.sort, self.sort_dir) }
pub(super) fn inventory_help_open(&self) -> bool { self.inventory_help_open }
pub(super) fn container_is_open(&self, item_id: i64) -> bool { self.expanded_containers.contains(&item_id) }
pub(super) fn container_children_of(&self, item_id: i64) -> Option<&[InventoryRow]> { self.inventory_children.get(&item_id).map(Vec::as_slice) }
pub(super) fn division_is_open(&self, office_item_id: i64, division_key: i64) -> bool { !self.collapsed_divisions.contains(&(office_item_id, division_key)) }
pub(super) fn hangar_division_name(&self, corporation_id: i64, division: i64) -> Option<&str> { self .hangar_division_names .get(&(corporation_id, division)) .map(String::as_str) }
pub(super) fn search(&self) -> &str { &self.search }
pub(super) fn category(&self) -> Category { self.category }
pub(super) fn saved_filters(&self) -> &[SavedAssetFilter] { &self.saved_filters }
pub(super) fn saved_filter_active(&self) -> Option<i64> { self.saved_filter_active }
pub(super) fn saved_filter_modal_open(&self) -> bool { self.saved_filter_modal_open }
pub(super) fn saved_filter_draft_name(&self) -> &str { &self.saved_filter_draft_name }
pub(super) fn saved_filter_context_menu(&self) -> Option<&SavedFilterContextMenu> { self.saved_filter_context_menu.as_ref() }
// The string captured by a "save filter" action: the raw query when present, // else `category:<key>` when only a category pill is set, else empty. pub(super) fn save_filter_capture(&self) -> String { let search = self.search.trim(); if !search.is_empty() { return search.to_owned(); } match self.category.key() { Some(key) => format!("category:{key}"), None => String::new(), } }
pub(super) fn can_save_filter(&self) -> bool { !self.search.trim().is_empty() || self.category != Category::All }
pub(super) fn roster(&self) -> &[RosterPilot] { &self.roster }
pub(super) fn corporations(&self) -> &[RosterCorp] { &self.corporations }
pub(super) fn geo_tree(&self) -> &GeoTree { &self.geo_tree }
pub(super) fn geo_selected(&self) -> GeoSelection { self.geo_selected }
pub(super) fn values(&self) -> &values::ValueSummary { &self.values }
pub(super) fn nav(&self) -> &tracker::NavSeries { &self.nav }
pub(super) fn chart_hover(&self) -> Option<f32> { self.chart_hover }
pub(super) fn stockpiles(&self) -> &[stockpiles::StockpileCard] { &self.stockpiles }
/// The card matching `id`, so the app layer can clone it to seed an Edit window. Returns `None` for /// a stale id (the card list may have reloaded out from under a pending context-menu action). pub fn stockpile_card(&self, id: i64) -> Option<&stockpiles::StockpileCard> { self.stockpiles.iter().find(|card| card.id == id) }
/// Closes the open stockpile context menu, used by the app layer when opening an editor window from /// the menu's Edit action so the menu doesn't linger over the main view. pub fn dismiss_stockpile_context_menu(&mut self) { self.stockpile_context_menu = None; }
pub(super) fn stockpile_context_menu(&self) -> Option<&StockpileContextMenu> { self.stockpile_context_menu.as_ref() }
pub(super) fn stockpile_expanded(&self) -> &HashSet<i64> { &self.stockpile_expanded }
pub(super) fn stockpile_multibuy_export(&self) -> Option<i64> { self.stockpile_multibuy_export }
pub(super) fn stockpile_multibuy_copied(&self) -> bool { self.stockpile_multibuy_copied }
pub(super) fn stockpile_multibuy_mode(&self) -> stockpiles::MultibuyMode { self.stockpile_multibuy_mode }
pub(super) fn abyssals(&self) -> &[abyssals::AbyssalCard] { &self.abyssals }
pub(super) fn abyssal_total(&self) -> i64 { self.abyssal_total }
pub(super) fn abyssal_source_types(&self) -> &[abyssals::SourceTypeFilter] { &self.abyssal_source_types }
pub(super) fn abyssal_filters(&self) -> &abyssals::Filters { &self.abyssal_filters }
pub(super) fn abyssal_picker_open(&self) -> bool { self.abyssal_picker_open }
pub(super) fn abyssal_stat_templates(&self) -> &[StatTemplate] { &self.abyssal_stat_templates }
pub(super) fn abyssal_scroll_offset(&self) -> f32 { self.abyssal_scroll_offset }
pub(super) fn abyssal_slider_edit(&self) -> Option<(i64, SliderEndpoint)> { self.abyssal_slider_edit }
pub(super) fn abyssal_slider_edit_text(&self) -> &str { &self.abyssal_slider_edit_text }
pub(super) fn geo_is_collapsed(&self, key: GeoNodeKey) -> bool { !self.geo_expanded.contains(&key) }
#[cfg(test)] pub(super) fn set_geo_tree_for_test(&mut self, tree: GeoTree) { self.geo_tree = tree; }
#[cfg(test)] pub(super) fn set_picker_for_test(&mut self, active: Scope, roster: Vec<RosterPilot>, corporations: Vec<RosterCorp>) { self.active = active; self.roster = roster; self.corporations = corporations; }
#[cfg(test)] pub(super) fn set_abyssals_for_test( &mut self, cards: Vec<abyssals::AbyssalCard>, source_types: Vec<abyssals::SourceTypeFilter>, filters: abyssals::Filters, picker_open: bool, ) { self.abyssals = cards; self.abyssal_source_types = source_types; self.abyssal_filters = filters; self.abyssal_picker_open = picker_open; }
#[cfg(test)] pub(super) fn set_abyssal_pagination_for_test(&mut self, has_more: bool, loading: bool) { self.abyssal_has_more = has_more; self.abyssal_loading = loading; }
#[cfg(test)] pub(super) fn abyssal_has_more(&self) -> bool { self.abyssal_has_more }
#[cfg(test)] pub(super) fn abyssal_loading(&self) -> bool { self.abyssal_loading }
#[cfg(test)] pub(super) fn set_abyssal_stat_templates_for_test(&mut self, templates: Vec<StatTemplate>) { self.abyssal_stat_templates = templates; }
#[cfg(test)] pub(super) fn set_for_test( &mut self, scope: Scope, roster: Vec<RosterPilot>, inventory: Vec<InventoryRow>, search: String, ) { self.active = scope; self.roster = roster; self.inventory = inventory; self.search = search; }
#[cfg(test)] pub(super) fn set_inventory_children_for_test(&mut self, container_id: i64, children: Vec<InventoryRow>) { self.expanded_containers.insert(container_id); self.inventory_children.insert(container_id, children); }
pub(super) fn pane(&self, pane: Pane) -> &PaneDrag { match pane { Pane::Sidebar => &self.sidebar, Pane::AbyssalsFilter => &self.abyssals_filter, } }
fn active_drag(&self) -> Option<Pane> { if self.sidebar.is_active() { Some(Pane::Sidebar) } else if self.abyssals_filter.is_active() { Some(Pane::AbyssalsFilter) } else { None } }
fn pane_mut(&mut self, pane: Pane) -> (&mut PaneDrag, &'static str) { match pane { Pane::Sidebar => (&mut self.sidebar, SIDEBAR_PANE_KEY), Pane::AbyssalsFilter => (&mut self.abyssals_filter, ABYSSALS_FILTER_PANE_KEY), } }}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub enum Tab { Abyssals, #[default] Inventory, Stockpiles, Tracker, Values,}
impl Tab { const ORDER: [Tab; 5] = [ Tab::Inventory, Tab::Abyssals, Tab::Stockpiles, Tab::Values, Tab::Tracker, ];
pub fn from_id(id: &str) -> Option<Tab> { match id { "abyssals" => Some(Tab::Abyssals), "inventory" => Some(Tab::Inventory), "stockpiles" => Some(Tab::Stockpiles), "tracker" => Some(Tab::Tracker), "values" => Some(Tab::Values), _ => None, } }
pub fn id(self) -> &'static str { match self { Tab::Abyssals => "abyssals", Tab::Inventory => "inventory", Tab::Stockpiles => "stockpiles", Tab::Tracker => "tracker", Tab::Values => "values", } }
pub(super) fn read_scopes(self) -> Vec<&'static str> { crate::features::shell::registry::sub_descriptor(self.sub_feature()) .scopes .iter() .copied() .filter(|scope| !crate::clients::esi::scopes::is_write_scope(scope)) .collect() }
pub(super) fn sub_feature(self) -> crate::config::SubFeature { match self { Tab::Abyssals => crate::config::SubFeature::Abyssals, Tab::Inventory => crate::config::SubFeature::Inventory, Tab::Stockpiles => crate::config::SubFeature::Stockpiles, Tab::Tracker => crate::config::SubFeature::Tracker, Tab::Values => crate::config::SubFeature::Values, } }}
pub(super) fn enabled_tabs(flags: &crate::config::FeatureFlags) -> Vec<Tab> { Tab::ORDER .into_iter() .filter(|tab| flags.is_sub_enabled(tab.sub_feature())) .collect()}
pub(super) fn resolve_first_tab(enabled: &[Tab]) -> Tab { enabled.first().copied().unwrap_or_default()}
#[derive(Clone, Debug)]pub(super) struct SavedFilterContextMenu { pub anchor: iced::Point, pub id: i64, pub name: String,}
#[derive(Clone, Debug)]pub(super) struct StockpileContextMenu { pub anchor: iced::Point, pub id: i64, pub name: String,}
#[derive(Clone, Debug)]struct InventoryView { filter: String, limit: i64, location_ids: Vec<i64>, sort: SortColumn, sort_dir: SortDirection,}
impl Default for InventoryView { fn default() -> Self { Self { filter: String::new(), limit: INVENTORY_PAGE_SIZE, location_ids: Vec::new(), sort: SortColumn::Value, sort_dir: SortDirection::Descending, } }}
impl InventoryView { fn from_state(state: &State) -> Self { Self { filter: effective_filter(state.category, &state.search), limit: INVENTORY_PAGE_SIZE, location_ids: location_ids_for_selection(&state.geo_tree, state.geo_selected), sort: state.sort, sort_dir: state.sort_dir, } }
fn with_limit(mut self, limit: i64) -> Self { self.limit = limit; self }}
#[derive(Clone, Debug, Eq, PartialEq)]enum Owner { Character(i64), Combined { character_ids: Vec<i64>, corporation_ids: Vec<i64>, }, Corporation(i64),}
fn resolve_scope_owner(scope: Scope, roster: &[RosterPilot], corporations: &[RosterCorp]) -> Option<Owner> { match scope { Scope::All => Some(Owner::Combined { character_ids: roster.iter().map(|pilot| pilot.id).collect(), corporation_ids: corporations.iter().map(|corp| corp.id).collect(), }), Scope::Character(id) => Some(Owner::Character(id)), Scope::Corporation(id) => corporations .iter() .any(|corp| corp.id == id) .then_some(Owner::Corporation(id)), }}
pub fn load(db: &Database) -> Task<Message> { Task::perform( load_assets(db.clone(), Scope::All, InventoryView::default()), |loaded| Message::Loaded(Box::new(loaded)), )}
pub fn sync_reload(state: &State, db: &Database) -> Task<Message> { let generation = state.search_generation; let loaded_rows = (state.inventory.len() as i64).max(INVENTORY_PAGE_SIZE); let view = InventoryView::from_state(state).with_limit(loaded_rows); Task::perform(load_assets(db.clone(), state.active, view), move |loaded| { Message::SyncReloaded { generation, loaded: Box::new(loaded), } })}
fn reload(db: &Database, scope: Scope, inventory: InventoryView) -> Task<Message> { Task::perform(load_assets(db.clone(), scope, inventory), |loaded| { Message::Loaded(Box::new(loaded)) })}
fn reload_filtered(state: &mut State, db: &Database) -> Task<Message> { state.search_generation = state.search_generation.wrapping_add(1); let load = reload(db, state.active, InventoryView::from_state(state)); Task::batch([snap_inventory_to_top(state), load])}
/// Returns the inventory list to the top, resetting both the persisted offset and the physical scrollable.////// Both are required: the state reset alone leaves the physical scrollable parked, and a `scroll_to`/// alone would be undone when the next render re-applies the stale stored offset.fn snap_inventory_to_top(state: &mut State) -> Task<Message> { state.inventory_scroll_offset = 0.0; operation::scroll_to( inventory::INVENTORY_SCROLL_ID, operation::AbsoluteOffset { x: 0.0, y: 0.0, }, )}
fn trigger_search(state: &mut State, db: &Database) -> Task<Message> { state.search_generation = state.search_generation.wrapping_add(1); run_search( db.clone(), state.active, InventoryView::from_state(state), state.search_generation, )}
fn run_search(db: Database, scope: Scope, view: InventoryView, generation: u64) -> Task<Message> { Task::perform( async move { tokio::time::sleep(Duration::from_millis(SEARCH_DEBOUNCE_MS)).await; load_assets(db, scope, view).await }, move |loaded| Message::SearchReloaded { generation, loaded: Box::new(loaded), }, )}
fn apply_loaded(state: &mut State, loaded: Loaded) { let Loaded { asset_tags, corporations, expand_containers, geo_tree, hangar_divisions, inventory, inventory_children, roster, saved_filters, tag_memberships, totals, values, nav, stockpiles, abyssals, } = loaded; state.asset_tags = asset_tags; state.tag_memberships = tag_memberships; state.corporations = corporations; state.saved_filters = saved_filters; state.inventory_page_epoch.next(); state.abyssal_page_epoch.next(); state.inventory_has_more = inventory.len() as i64 == INVENTORY_PAGE_SIZE; state.inventory = inventory; state.inventory_loading = false; // Seed the filter-driven auto-expansion. With no filter active both are empty, so this collapses // back to normal browse (clearing any prior filter expansions); with a filter active it reveals // the ancestor chain holding each nested match. state.expanded_containers = expand_containers.into_iter().collect(); state.inventory_children = inventory_children; state.roster = roster; state.totals = totals; state.values = values; state.nav = nav; state.stockpiles = stockpiles; state.abyssal_has_more = abyssals.cards.len() as i64 == abyssals::PAGE_SIZE; state.abyssal_loading = false; state.abyssal_scroll_offset = 0.0; state.abyssal_total = abyssals.total; state.abyssals = abyssals.cards; state.abyssal_source_types = abyssals.source_types; state.abyssal_filters = abyssals::Filters::default(); state.abyssal_picker_open = false; state.abyssal_slider_edit = None; state.abyssal_slider_edit_text = String::new(); state.abyssal_stat_templates = Vec::new(); state.geo_tree = geo_tree; state.geo_tree.sort_by(state.geo_sort()); state.hangar_division_names = hangar_divisions; prune_inventory_selection(state);}
/// Drops selected rows that a reload, filter change, or container collapse removed from the live/// inventory so a stale selection never points at rows the user can no longer see, and clears the/// open context menu when its target selection emptied out.fn prune_inventory_selection(state: &mut State) { let order = state.inventory_order(); state.inventory_selection.prune(&order); if state.inventory_selection.is_empty() { state.inventory_menu = None; }}
/// Refreshes data from a sync-triggered reload while preserving transient interaction state/// (expanded containers, search text, abyssal filters, slider edits, and picker state).////// Contrast with `apply_loaded`, which resets all of that state on every reload.fn merge_loaded(state: &mut State, loaded: Loaded) { let Loaded { asset_tags, corporations, expand_containers, geo_tree, hangar_divisions, inventory, inventory_children, roster, saved_filters, tag_memberships, totals, values, nav, stockpiles, abyssals, } = loaded; state.asset_tags = asset_tags; state.tag_memberships = tag_memberships; state.corporations = corporations; state.saved_filters = saved_filters; state.inventory_page_epoch.next(); state.abyssal_page_epoch.next(); // Compare fresh page length against the limit that was requested (old inventory len or minimum // page size), not the constant — sync reloads may fetch more than one page worth. state.inventory_has_more = inventory.len() as i64 == (state.inventory.len() as i64).max(INVENTORY_PAGE_SIZE); state.inventory = inventory; state.roster = roster; state.totals = totals; state.values = values; state.nav = nav; state.stockpiles = stockpiles; state.abyssal_has_more = abyssals.cards.len() as i64 == abyssals::PAGE_SIZE; state.abyssal_loading = false; state.abyssal_total = abyssals.total; state.abyssals = abyssals.cards; state.abyssal_source_types = abyssals.source_types; state.geo_tree = geo_tree; state.geo_tree.sort_by(state.geo_sort()); state.hangar_division_names = hangar_divisions; // Fold the filter-driven auto-expansion into the preserved manual expansions so a sync reload // keeps nested matches revealed, then drop any expansion whose container row is no longer present. for container_id in expand_containers { state.expanded_containers.insert(container_id); } for (container_id, children) in inventory_children { state.inventory_children.insert(container_id, children); } // A container is "present" if its row appears anywhere in the live tree — top-level or as a loaded // child of an expanded ancestor — so nested ancestors in an auto-expanded chain are not pruned. let present: HashSet<i64> = state .inventory .iter() .chain(state.inventory_children.values().flatten()) .map(|row| row.item_id) .collect(); state.expanded_containers.retain(|id| present.contains(id)); state.inventory_children.retain(|id, _| present.contains(id)); prune_inventory_selection(state);}
fn effective_filter(category: Category, search: &str) -> String { match category.key() { Some(key) => { let search = search.trim(); if search.is_empty() { format!("category:{key}") } else { format!("category:{key} {search}") } } None => search.to_owned(), }}
pub fn update(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::AssetChartHovered(_) | Message::CategorySelected(_) | Message::FilterExamplePicked(_) | Message::InventoryHelpToggled | Message::Loaded(_) | Message::PickerToggled | Message::ScopeSelected(_) | Message::SearchChanged(_) | Message::SearchReloaded { .. } | Message::SearchSubmitted | Message::SortSelected(_) | Message::SyncReloaded { .. } | Message::TabSelected(_) => update_inventory(state, message, db),
Message::ContainerChildrenLoaded(..) | Message::ContainerToggled(_) | Message::DivisionToggled(..) | Message::InventoryPageLoaded { .. } | Message::InventoryScrolled { .. } => update_pagination(state, message, db),
Message::GeoNodeSelected(_) | Message::GeoNodeToggled(_) | Message::LocSortSelected(_) | Message::UiFlagSet(..) | Message::UiFlagPersisted(..) => update_geo(state, message, db),
Message::AssetTagModal(_) | Message::AssetTagsReloaded { .. } | Message::InventoryCursorMoved(_) | Message::InventoryModifiersChanged(_) | Message::InventoryMenuDismissed | Message::InventoryRowClicked(_) | Message::InventoryRowRightPressed(_) | Message::OpenSelectionTagModal => update_asset_tags(state, message, db),
Message::SaveFilterCancelled | Message::SaveFilterConfirmed | Message::SaveFilterNameChanged(_) | Message::SaveFilterOpened | Message::SavedFilterContextMenuClosed | Message::SavedFilterCreated(..) | Message::SavedFilterDeleted(_) | Message::SavedFilterRightPressed(_) | Message::SavedFilterSelected(_) | Message::SavedFiltersReloaded(_) | Message::SidebarCursorMoved(_) => update_saved_filter(state, message, db),
Message::StockpileCardRightPressed(_) | Message::StockpileContextMenuClosed | Message::StockpileCursorMoved(_) | Message::StockpileDeleted(_) | Message::StockpileEditStarted(_) | Message::StockpileEditorClosed | Message::StockpileEditorItemPicked(..) | Message::StockpileEditorItemRemoved(_) | Message::StockpileEditorItemResults(..) | Message::StockpileEditorItemSearchChanged(..) | Message::StockpileEditorItemTargetChanged(..) | Message::StockpileEditorLocationCleared | Message::StockpileEditorLocationPicked(_) | Message::StockpileEditorLocationResults(..) | Message::StockpileEditorLocationSearchChanged(_) | Message::StockpileEditorLocationToggled | Message::StockpileEditorNameChanged(_) | Message::StockpileEditorPopoversClosed | Message::StockpileEditorSaved | Message::StockpileEditorScopeChanged(_) | Message::StockpileEditorScopeResolved(_) | Message::StockpileImportClosed | Message::StockpileImportConfirmed | Message::StockpileImportOpened | Message::StockpileImportResolveRequested | Message::StockpileImportResolved(_) | Message::StockpileImportTextChanged(_) | Message::StockpileItemsToggled(_) | Message::StockpileMultibuyExportClosed | Message::StockpileMultibuyExportCopied(_) | Message::StockpileMultibuyExportOpened(_) | Message::StockpileMultibuyModeChanged(_) | Message::StockpileNew | Message::StockpilesReloaded(_) => update_stockpile(state, message, db),
Message::AbyssalCardsReloaded(_) | Message::AbyssalFilterReset | Message::AbyssalGridScrolled { .. } | Message::AbyssalMutaMarketOpened(_) | Message::AbyssalPageLoaded { .. } | Message::AbyssalPickerToggled | Message::AbyssalSliderEditCommitted(..) | Message::AbyssalSliderEditInput(_) | Message::AbyssalSliderEditStarted(..) | Message::AbyssalSourceTypeSelected(_) | Message::AbyssalStatMaxChanged(..) | Message::AbyssalStatMinChanged(..) | Message::AbyssalStatRangeChanged(..) | Message::AbyssalStatTemplatesLoaded(_) | Message::AbyssalTypeModalClosed | Message::AbyssalTypeModalOpened => update_abyssal(state, message, db),
Message::PaneDrag(_) | Message::PaneDragEnd | Message::PaneDragStart(_) | Message::PaneSettled(..) => { update_pane(state, message) }
Message::FeaturesChanged(features) => { let prev = state.tab; state.sync_features(features); if state.tab != prev { state.chart_hover = None; return reload_filtered(state, db); } Task::none() }
Message::ReauthRequested(_) => Task::none(), }}
fn update_inventory(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::Loaded(loaded) => { apply_loaded(state, *loaded); Task::none() } Message::SearchReloaded { generation, loaded, } => { if generation == state.search_generation { apply_loaded(state, *loaded); } Task::none() } Message::SyncReloaded { generation, loaded, } => { if generation == state.search_generation { merge_loaded(state, *loaded); } Task::none() } Message::PickerToggled => { state.picker_open = !state.picker_open; Task::none() } Message::ScopeSelected(scope) => { state.picker_open = false; if scope == state.active { return Task::none(); } state.active = scope; state.chart_hover = None; state.geo_selected = GeoSelection::All; state.geo_expanded.clear(); state.saved_filter_active = None; reload_filtered(state, db) } Message::TabSelected(tab) => { state.chart_hover = None; state.tab = tab; Task::none() } Message::AssetChartHovered(fraction) => { state.chart_hover = fraction; Task::none() } Message::SearchChanged(query) => { state.search = query; trigger_search(state, db) } Message::SearchSubmitted => Task::batch([snap_inventory_to_top(state), trigger_search(state, db)]), Message::CategorySelected(category) => { if state.category == category { return Task::none(); } state.category = category; reload_filtered(state, db) } Message::SortSelected(column) => { if state.sort == column { state.sort_dir = match state.sort_dir { SortDirection::Ascending => SortDirection::Descending, SortDirection::Descending => SortDirection::Ascending, }; } else { state.sort = column; state.sort_dir = SortDirection::Descending; } reload_filtered(state, db) } Message::InventoryHelpToggled => { state.inventory_help_open = !state.inventory_help_open; Task::none() } Message::FilterExamplePicked(query) => { state.search = query.to_owned(); state.inventory_help_open = false; reload_filtered(state, db) } _ => Task::none(), }}
fn update_geo(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::GeoNodeToggled(key) => { if !state.geo_expanded.remove(&key) { state.geo_expanded.insert(key); } Task::none() } Message::GeoNodeSelected(selection) => { if state.geo_selected == selection { return Task::none(); } state.geo_selected = selection; // Selecting a location clears any active saved filter (mutually exclusive). if !matches!(selection, GeoSelection::All) { state.saved_filter_active = None; } reload_filtered(state, db) } Message::LocSortSelected(mode) => { if state.geo_sort() == mode { return Task::none(); } // Record the mode locally and re-sort the already-loaded tree in place — // collapse and selection state live elsewhere and are untouched — then ask // the app layer to persist the flag. let alpha = matches!(mode, GeoSort::Alpha); state.ui_flags.insert(LOC_SORT_ALPHA_FLAG.to_owned(), alpha); state.geo_tree.sort_by(mode); Task::done(Message::UiFlagSet(LOC_SORT_ALPHA_FLAG.to_owned(), alpha)) } Message::UiFlagSet(key, value) => { state.ui_flags.insert(key.clone(), value); Task::done(Message::UiFlagPersisted(key, value)) } // The app-level assets handler records the flag into `UiState`; nothing to do // here once local state already carries it. Message::UiFlagPersisted(..) => Task::none(), _ => Task::none(), }}
fn update_saved_filter(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::SidebarCursorMoved(point) => { state.sidebar_cursor = Some(point); Task::none() } Message::SaveFilterOpened => { state.saved_filter_modal_open = true; state.saved_filter_draft_name = String::new(); Task::none() } Message::SaveFilterCancelled => { state.saved_filter_modal_open = false; state.saved_filter_draft_name = String::new(); Task::none() } Message::SaveFilterNameChanged(name) => { state.saved_filter_draft_name = name; Task::none() } Message::SaveFilterConfirmed => { let name = state.saved_filter_draft_name.trim().to_owned(); if name.is_empty() { return Task::none(); } let query = state.search.trim().to_owned(); let category = state.category.key().map(str::to_owned); state.saved_filter_modal_open = false; state.saved_filter_draft_name = String::new(); let db = db.clone(); Task::perform( async move { let created = assets::create_saved_filter(&db, &name, &query, category.as_deref()).await; let filters = assets::saved_filters(&db).await.unwrap_or_default(); (created.ok().map(|filter| filter.id()), filters) }, |(new_id, filters)| Message::SavedFilterCreated(new_id, filters), ) } Message::SavedFilterCreated(new_id, filters) => { state.saved_filters = filters; let Some(id) = new_id else { return Task::none(); }; // Selecting the new filter clears any geo selection (mutually exclusive). state.saved_filter_active = Some(id); state.geo_selected = GeoSelection::All; Task::none() } Message::SavedFiltersReloaded(filters) => { state.saved_filters = filters; Task::none() } Message::SavedFilterSelected(id) => { // Re-clicking the active filter clears it. if state.saved_filter_active == Some(id) { state.saved_filter_active = None; state.search = String::new(); state.category = Category::All; return reload_filtered(state, db); } let Some(filter) = state.saved_filters.iter().find(|filter| filter.id() == id) else { return Task::none(); }; state.saved_filter_active = Some(id); state.search = filter.query().to_owned(); state.category = Category::from_key(filter.category().as_deref()); // Mutually exclusive with geo selection. state.geo_selected = GeoSelection::All; reload_filtered(state, db) } Message::SavedFilterRightPressed(id) => { if let (Some(anchor), Some(filter)) = ( state.sidebar_cursor, state.saved_filters.iter().find(|filter| filter.id() == id), ) { state.saved_filter_context_menu = Some(SavedFilterContextMenu { anchor, id, name: filter.name().to_owned(), }); } Task::none() } Message::SavedFilterContextMenuClosed => { state.saved_filter_context_menu = None; Task::none() } Message::SavedFilterDeleted(id) => { state.saved_filter_context_menu = None; if state.saved_filter_active == Some(id) { state.saved_filter_active = None; } let db = db.clone(); Task::perform( async move { assets::delete_saved_filter(&db, id).await.ok(); assets::saved_filters(&db).await.unwrap_or_default() }, Message::SavedFiltersReloaded, ) } _ => Task::none(), }}
/// Drives the per-row asset-tag chips and the shared add-tag modal: open/close, search input, and the/// assign/create/unassign writes (each followed by a registry+membership reload so chips update).////// The state/message surface here is the one phase 4 (multi-select Edit Tags) reuses: an `Option<AddTagModal>`/// host, the `AssetTagModal(AddTagMessage)` mapper arm, and the `reload_asset_tags` write-then-reload task./// Phase 4 adds a sibling open path over a selection but routes its modal messages through this same arm.fn update_asset_tags(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::OpenSelectionTagModal => { let order = state.inventory_order(); let selected = state.inventory_selection.ordered(&order); if selected.is_empty() { return Task::none(); } state.inventory_menu = None; state.add_tag_modal_name = selection_modal_name(state, &selected); // The modal's single `entity_id` is the first selected stack (a stand-in); assign/unassign // fan across every id in `selection_tag_ids`, so bulk acts on the whole selection. state.add_tag_modal = Some(AddTagModal::new(selected[0], ENTITY_TYPE_ASSET)); state.selection_tag_ids = selected; Task::none() } Message::InventoryModifiersChanged(modifiers) => { state.inventory_modifiers = modifiers; Task::none() } Message::InventoryCursorMoved(point) => { state.inventory_cursor = Some(point); Task::none() } Message::InventoryRowClicked(item_id) => { let order = state.inventory_order(); let kind = ClickKind::from_modifiers(state.inventory_modifiers.command(), state.inventory_modifiers.shift()); state.inventory_selection.apply(item_id, kind, &order); Task::none() } Message::InventoryRowRightPressed(item_id) => { // Right-clicking a row outside the current selection makes it the selection so the menu always // acts on what the user pointed at. if !state.inventory_selection.contains(item_id) { let order = state.inventory_order(); state.inventory_selection.apply(item_id, ClickKind::Plain, &order); } if let Some(anchor) = state.inventory_cursor { state.inventory_menu = Some(anchor); } Task::none() } Message::InventoryMenuDismissed => { state.inventory_menu = None; Task::none() } Message::AssetTagsReloaded { memberships, tags, } => { state.asset_tags = tags; state.tag_memberships = memberships; Task::none() } Message::AssetTagModal(modal_message) => apply_asset_tag_modal(state, modal_message, db), _ => Task::none(), }}
/// The modal header for a bulk Edit Tags: the lone stack's name for a single selection, otherwise an/// "N stacks" count so the modal reads as acting on the whole selection.fn selection_modal_name(state: &State, selected: &[i64]) -> String { match selected { [only] => state.resolve_item_name(*only), many => t!("assets.inventory.selection_stacks", count => many.len()).into_owned(), }}
/// Fans a closure across the target stacks, building one task per id and batching them, each/// followed by the shared tag reload. When a bulk modal is open the targets are the whole selection/// (`selection_tag_ids`); otherwise the write applies to just `entity_id` — the stack named by the/// message itself, as when a per-row tag chip's `×` unassigns directly without opening the modal.fn fan_tag_write<F>(state: &State, db: &Database, entity_id: i64, write: F) -> Task<Message>where F: Fn(Database, i64) -> Task<Message>,{ let tasks: Vec<Task<Message>> = tag_write_targets(state, entity_id) .into_iter() .map(|item_id| write(db.clone(), item_id)) .collect(); Task::batch(tasks)}
/// The stacks a tag assign/unassign should apply to: the whole bulk selection when a modal has/// populated `selection_tag_ids`, otherwise just `entity_id` — the stack named by the message, as/// when a per-row chip's `×` unassigns directly without any modal open.fn tag_write_targets(state: &State, entity_id: i64) -> Vec<i64> { if state.selection_tag_ids.is_empty() { vec![entity_id] } else { state.selection_tag_ids.clone() }}
fn apply_asset_tag_modal(state: &mut State, message: AddTagMessage, db: &Database) -> Task<Message> { match message { AddTagMessage::InputChanged(value) => { if let Some(modal) = &mut state.add_tag_modal { modal.input = value; } Task::none() } AddTagMessage::Close => { state.add_tag_modal = None; state.selection_tag_ids = Vec::new(); Task::none() } AddTagMessage::Assign { entity_id, entity_type, tag_id, } => fan_tag_write(state, db, entity_id, move |db, item_id| { Task::perform(assign_then_reload(db, entity_type, item_id, tag_id), |message| message) }), AddTagMessage::Unassign { entity_id, entity_type, tag_id, } => fan_tag_write(state, db, entity_id, move |db, item_id| { Task::perform(unassign_then_reload(db, entity_type, item_id, tag_id), |message| { message }) }), AddTagMessage::CreateAndAssign { entity_type, .. } => { let Some(name) = state .add_tag_modal .as_ref() .map(|modal| modal.input.trim().to_owned()) .filter(|name| !name.is_empty()) else { return Task::none(); }; if state.selection_tag_ids.is_empty() { return Task::none(); } // Reuse an existing asset tag of the same name rather than creating a duplicate. This in-memory // fast path keeps the UX snappy, but it MUST normalize names exactly like the repo's find-or-create // (`normalize_tag_name`, which mirrors the store's `(scope, lower(name))` key) so it never diverges // from what the store would do. The bespoke `eq_ignore_ascii_case` it replaces also folded ASCII // case, but did not trim — so a padded re-entry of the same name could slip past it and duplicate. let needle = normalize_tag_name(&name); let existing = state .asset_tags .iter() .find(|tag| normalize_tag_name(tag.name()) == needle) .map(Tag::id); if let Some(modal) = &mut state.add_tag_modal { modal.input.clear(); } // Resolve (or create) the tag once, then assign it to every selected stack so a bulk create tags // the whole selection in one action. let item_ids = state.selection_tag_ids.clone(); let db = db.clone(); Task::perform( create_or_reuse_then_assign(db, name, existing, entity_type, item_ids), |message| message, ) } }}
/// Assign `tag_id` to one stack, then reload the asset-tag registry. The body of the modal's `Assign`/// arm, lifted into a named future so the write-then-reload path is directly testable.async fn assign_then_reload(db: Database, entity_type: &'static str, item_id: i64, tag_id: i64) -> Message { infra::assign(&db, entity_type, item_id, tag_id).await.ok(); reload_asset_tags(db).await}
/// Unassign `tag_id` from one stack, then reload the asset-tag registry. The body of the modal's/// `Unassign` arm, lifted into a named future so the write-then-reload path is directly testable.async fn unassign_then_reload(db: Database, entity_type: &'static str, item_id: i64, tag_id: i64) -> Message { infra::unassign(&db, entity_type, item_id, tag_id).await.ok(); reload_asset_tags(db).await}
/// Resolve the tag — reusing `existing` when set, otherwise creating it — then assign it to every/// stack in `item_ids`, and reload. The body of the modal's `CreateAndAssign` arm, lifted into a/// named future so the create/reuse-then-bulk-assign path is directly testable.async fn create_or_reuse_then_assign( db: Database, name: String, existing: Option<i64>, entity_type: &'static str, item_ids: Vec<i64>,) -> Message { let tag_id = match existing { Some(id) => Some(id), None => infra::create_scoped(&db, &name, None, None, TAG_SCOPE_ASSET) .await .ok() .map(|tag| tag.id()), }; if let Some(tag_id) = tag_id { for item_id in item_ids { infra::assign(&db, entity_type, item_id, tag_id).await.ok(); } } reload_asset_tags(db).await}
/// Depth-first walk of the inventory order: a row, then any loaded children of an expanded container./// Mirrors `inventory::flatten_rows` so the selection's range/highlight resolve against the same order/// the table renders.fn push_inventory_order(state: &State, out: &mut Vec<i64>, row: &InventoryRow) { out.push(row.item_id); if !(row.is_container && state.expanded_containers.contains(&row.item_id)) { return; } let Some(children) = state.inventory_children.get(&row.item_id) else { return; }; if inventory::is_office(row) { let (divisions, office_root) = inventory::group_office_children(children); for (division, items) in divisions { if state.division_is_open(row.item_id, division.expand_key()) { for item in items { push_inventory_order(state, out, item); } } } for item in office_root { push_inventory_order(state, out, item); } } else { for child in children { push_inventory_order(state, out, child); } }}
fn location_ids_for_selection(tree: &GeoTree, selection: GeoSelection) -> Vec<i64> { match selection { GeoSelection::All => Vec::new(), GeoSelection::Location(location_id) => vec![location_id], GeoSelection::Region(region_id) => tree .regions .iter() .find(|region| region.region_id == region_id) .map(|region| { region .constellations .iter() .flat_map(|constellation| &constellation.systems) .flat_map(|system| &system.locations) .map(|location| location.location_id) .collect() }) .unwrap_or_default(), GeoSelection::Constellation(constellation_id) => tree .regions .iter() .flat_map(|region| ®ion.constellations) .find(|constellation| constellation.constellation_id == constellation_id) .map(|constellation| { constellation .systems .iter() .flat_map(|system| &system.locations) .map(|location| location.location_id) .collect() }) .unwrap_or_default(), GeoSelection::System(system_id) => tree .regions .iter() .flat_map(|region| ®ion.constellations) .flat_map(|constellation| &constellation.systems) .find(|system| system.system_id == system_id) .map(|system| system.locations.iter().map(|location| location.location_id).collect()) .unwrap_or_default(), }}
fn update_pagination(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::InventoryScrolled { absolute, relative, } => { state.inventory_scroll_offset = absolute; if relative < INVENTORY_SCROLL_THRESHOLD || !state.inventory_has_more || state.inventory_loading { return Task::none(); } let Some(cursor) = state.inventory.last().map(|row| row.cursor(state.sort)) else { return Task::none(); }; state.inventory_loading = true; let view = InventoryView::from_state(state); let epoch = state.inventory_page_epoch.current(); let (db, scope, roster, corporations) = ( db.clone(), state.active, state.roster.clone(), state.corporations.clone(), ); Task::perform( async move { load_inventory_page(&db, scope, &roster, &corporations, &view, cursor).await }, move |rows| Message::InventoryPageLoaded { epoch, rows, }, ) } Message::InventoryPageLoaded { epoch, rows, } => { if !state.inventory_page_epoch.matches(epoch) { return Task::none(); } state.inventory_loading = false; state.inventory_has_more = rows.len() as i64 == INVENTORY_PAGE_SIZE; state.inventory.extend(rows); Task::none() } Message::ContainerToggled(item_id) => { if state.expanded_containers.remove(&item_id) { state.inventory_children.remove(&item_id); // The collapsed container's children left the loaded rows, so drop them from the selection. prune_inventory_selection(state); return Task::none(); } state.expanded_containers.insert(item_id); if state.inventory_children.contains_key(&item_id) { return Task::none(); } let (db, scope, roster, corporations) = ( db.clone(), state.active, state.roster.clone(), state.corporations.clone(), ); let filter = effective_filter(state.category, &state.search); Task::perform( async move { load_container_children(&db, scope, &roster, &corporations, item_id, &filter).await }, move |children| Message::ContainerChildrenLoaded(item_id, children), ) } Message::ContainerChildrenLoaded(item_id, children) => { if state.expanded_containers.contains(&item_id) { state.inventory_children.insert(item_id, children); } Task::none() } Message::DivisionToggled(office_item_id, division_key) => { let key = (office_item_id, division_key); if !state.collapsed_divisions.remove(&key) { state.collapsed_divisions.insert(key); } // A collapsed division hides its items, so drop them from the selection. prune_inventory_selection(state); Task::none() } _ => Task::none(), }}
fn update_stockpile(state: &mut State, message: Message, db: &Database) -> Task<Message> { update_stockpile_lifecycle(state, message, db)}
fn update_stockpile_lifecycle(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::StockpileDeleted(id) => { state.stockpile_context_menu = None; let db = db.clone(); Task::perform( async move { stockpiles::delete(&db, id).await; stockpiles::load_cards(&db).await }, Message::StockpilesReloaded, ) } Message::StockpilesReloaded(cards) => { state.stockpiles = cards; Task::none() } Message::StockpileCursorMoved(point) => { state.stockpile_cursor = Some(point); Task::none() } Message::StockpileCardRightPressed(id) => { if let (Some(anchor), Some(card)) = (state.stockpile_cursor, state.stockpiles.iter().find(|c| c.id == id)) { state.stockpile_context_menu = Some(StockpileContextMenu { anchor, id, name: card.name.clone(), }); } Task::none() } Message::StockpileContextMenuClosed => { state.stockpile_context_menu = None; Task::none() } Message::StockpileItemsToggled(id) => { if !state.stockpile_expanded.remove(&id) { state.stockpile_expanded.insert(id); } Task::none() } Message::StockpileMultibuyExportOpened(id) => { state.stockpile_context_menu = None; state.stockpile_multibuy_export = Some(id); state.stockpile_multibuy_copied = false; state.stockpile_multibuy_mode = stockpiles::MultibuyMode::default(); Task::none() } Message::StockpileMultibuyModeChanged(mode) => { state.stockpile_multibuy_mode = mode; state.stockpile_multibuy_copied = false; Task::none() } Message::StockpileMultibuyExportClosed => { state.stockpile_multibuy_export = None; state.stockpile_multibuy_copied = false; Task::none() } Message::StockpileMultibuyExportCopied(id) => { let Some(card) = state.stockpiles.iter().find(|card| card.id == id) else { return Task::none(); }; let text = stockpile_multibuy::serialize(&card.multibuy_lines(state.stockpile_multibuy_mode)); state.stockpile_multibuy_copied = true; iced::clipboard::write(text) } _ => Task::none(), }}
fn update_abyssal(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::AbyssalPickerToggled => { state.abyssal_picker_open = !state.abyssal_picker_open; Task::none() } Message::AbyssalTypeModalOpened => { state.abyssal_picker_open = true; Task::none() } Message::AbyssalTypeModalClosed => { state.abyssal_picker_open = false; Task::none() } Message::AbyssalSourceTypeSelected(type_id) => { if state.abyssal_filters.source_type_id != type_id { state.abyssal_filters.stat_ranges.clear(); } state.abyssal_filters.source_type_id = type_id; state.abyssal_picker_open = false; state.abyssal_slider_edit = None; state.abyssal_stat_templates = Vec::new(); Task::batch([ load_abyssal_templates(state, db, type_id), reload_abyssal_cards(state, db), ]) } Message::AbyssalStatRangeChanged(attribute_id, endpoint, value) => { let (min_value, max_value) = endpoint_values(endpoint, value); apply_stat_range_and_reload(state, db, attribute_id, min_value, max_value) } Message::AbyssalStatMinChanged(attribute_id, value) => { apply_stat_range_and_reload(state, db, attribute_id, Some(value), None) } Message::AbyssalStatMaxChanged(attribute_id, value) => { apply_stat_range_and_reload(state, db, attribute_id, None, Some(value)) } Message::AbyssalMutaMarketOpened(item_id) => { let _ = open::that_detached(format!("{MUTAMARKET_MODULE_URL}/{item_id}")); Task::none() } Message::AbyssalFilterReset => { state.abyssal_filters = abyssals::Filters::default(); state.abyssal_slider_edit = None; state.abyssal_slider_edit_text = String::new(); state.abyssal_stat_templates = Vec::new(); reload_abyssal_cards(state, db) } Message::AbyssalGridScrolled { absolute, relative, } => { state.abyssal_scroll_offset = absolute; if relative < ABYSSAL_SCROLL_THRESHOLD || !state.abyssal_has_more || state.abyssal_loading { return Task::none(); } let Some(cursor) = state.abyssals.last().map(abyssals::AbyssalCard::cursor) else { return Task::none(); }; state.abyssal_loading = true; load_abyssal_page(state, db, cursor) } Message::AbyssalPageLoaded { cards, epoch, } => { if !state.abyssal_page_epoch.matches(epoch) { return Task::none(); } state.abyssal_loading = false; state.abyssal_has_more = cards.len() as i64 == abyssals::PAGE_SIZE; state.abyssals.extend(cards); // Deliberately leave abyssal_total alone: it is the filter-aware DB count, not the loaded // length, so appending a scroll page must not overwrite it. Task::none() } Message::AbyssalStatTemplatesLoaded(templates) => { state.abyssal_stat_templates = templates; Task::none() } Message::AbyssalCardsReloaded(reload) => { let abyssals::FilteredCards { cards, total, } = reload; state.abyssal_page_epoch.next(); state.abyssal_has_more = cards.len() as i64 == abyssals::PAGE_SIZE; state.abyssal_loading = false; state.abyssal_scroll_offset = 0.0; state.abyssals = cards; state.abyssal_total = total; Task::none() } other => update_abyssal_slider(state, other, db), }}
fn update_abyssal_slider(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::AbyssalSliderEditStarted(attribute_id, endpoint, value) => { state.abyssal_slider_edit = Some((attribute_id, endpoint)); state.abyssal_slider_edit_text = format!("{value:.2}"); Task::none() } Message::AbyssalSliderEditInput(text) => { state.abyssal_slider_edit_text = text; Task::none() } Message::AbyssalSliderEditCommitted(attribute_id, endpoint) => { let parsed = state.abyssal_slider_edit_text.trim().parse::<f64>().ok(); state.abyssal_slider_edit = None; state.abyssal_slider_edit_text = String::new(); let Some(value) = parsed else { return Task::none(); }; let (min_value, max_value) = endpoint_values(endpoint, value); apply_stat_range_and_reload(state, db, attribute_id, min_value, max_value) } _ => Task::none(), }}
fn endpoint_values(endpoint: SliderEndpoint, value: f64) -> (Option<f64>, Option<f64>) { match endpoint { SliderEndpoint::Min => (Some(value), None), SliderEndpoint::Max => (None, Some(value)), }}
fn apply_stat_range_and_reload( state: &mut State, db: &Database, attribute_id: i64, min_value: Option<f64>, max_value: Option<f64>,) -> Task<Message> { if apply_abyssal_stat_range(state, attribute_id, min_value, max_value) { reload_abyssal_cards(state, db) } else { Task::none() }}
fn apply_abyssal_stat_range( state: &mut State, attribute_id: i64, min_value: Option<f64>, max_value: Option<f64>,) -> bool { let Some((lo, hi)) = state .abyssal_stat_templates .iter() .find(|template| template.attribute_id == attribute_id) .map(|template| (template.bound_lo, template.bound_hi)) else { return false; }; let (mut min, mut max) = state .abyssal_filters .stat_ranges .get(&attribute_id) .copied() .unwrap_or((lo, hi)); if let Some(value) = min_value { min = value.clamp(lo, max); } if let Some(value) = max_value { max = value.clamp(min, hi); } if min <= lo + 1e-6 && max >= hi - 1e-6 { state.abyssal_filters.stat_ranges.remove(&attribute_id); } else { state.abyssal_filters.stat_ranges.insert(attribute_id, (min, max)); } true}
fn load_abyssal_templates(state: &State, db: &Database, type_id: Option<i64>) -> Task<Message> { let Some(type_id) = type_id else { return Task::done(Message::AbyssalStatTemplatesLoaded(Vec::new())); }; let (db, scope, roster) = (db.clone(), state.active, state.roster.clone()); Task::perform( async move { abyssals::load_stat_templates(&db, scope, &roster, type_id).await }, Message::AbyssalStatTemplatesLoaded, )}
fn reload_abyssal_cards(state: &State, db: &Database) -> Task<Message> { let (db, scope, roster, filters) = ( db.clone(), state.active, state.roster.clone(), state.abyssal_filters.clone(), ); Task::perform( async move { abyssals::load_filtered_cards(&db, scope, &roster, &filters).await }, Message::AbyssalCardsReloaded, )}
/// Fetch the next cursor-delimited page of abyssal cards under the active filters.fn load_abyssal_page(state: &State, db: &Database, cursor: abyssals::AbyssalCursor) -> Task<Message> { let epoch = state.abyssal_page_epoch.current(); let (db, scope, roster, filters) = ( db.clone(), state.active, state.roster.clone(), state.abyssal_filters.clone(), ); Task::perform( async move { abyssals::load_filtered_page(&db, scope, &roster, &filters, Some(cursor)).await }, move |cards| Message::AbyssalPageLoaded { cards, epoch, }, )}
fn update_pane(state: &mut State, message: Message) -> Task<Message> { match message { Message::PaneDragStart(pane) => { state.pane_mut(pane).0.start(); Task::none() } Message::PaneDrag(x) => { if let Some(pane) = state.active_drag() { state.pane_mut(pane).0.drag_to(x); } Task::none() } Message::PaneDragEnd => { let Some(pane) = state.active_drag() else { return Task::none(); }; let (drag, key) = state.pane_mut(pane); drag.end(); let ratio = drag.ratio(); Task::done(Message::PaneSettled(key, ratio)) } _ => Task::none(), }}
pub fn subscription(state: &State) -> iced::Subscription<Message> { let mut subs: Vec<iced::Subscription<Message>> = Vec::new(); if state.active_drag().is_some() { subs.push(iced::event::listen_with(|event, _status, _id| { resizable_pane::drag_event(event, Message::PaneDrag, Message::PaneDragEnd) })); } // Track keyboard modifiers on the Inventory tab so a row click resolves its plain/range/toggle // intent, mirroring the wallet ledger. if state.tab == Tab::Inventory { subs.push(iced::event::listen_with(|event, _status, _id| match event { iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => { Some(Message::InventoryModifiersChanged(modifiers)) } _ => None, })); } iced::Subscription::batch(subs)}
pub fn view(state: &State, now: DateTime<Utc>) -> Element<'_, Message> { shell::shell(state, now)}
pub fn escape_dismiss(state: &State) -> Option<Message> { shell::escape_dismiss(state)}
async fn load_assets(db: Database, scope: Scope, view: InventoryView) -> Loaded { let roster = load_roster(&db).await; let corporations = load_corporations(&db).await;
let ScopeLoad { totals, inventory, expand_containers, inventory_children, } = match resolve_scope_owner(scope, &roster, &corporations) { Some(owner) => load_scope(&db, &owner, &view).await, None => ScopeLoad::default(), }; let geo_tree = tree::load_geo_tree(&db, scope, &roster, &corporations).await; let hangar_divisions = load_hangar_divisions(&db, &corporations).await; let values = values::load(&db, scope, &roster, &corporations).await; let nav = tracker::load_series(&db, scope).await; let stockpiles = stockpiles::load_cards(&db).await; let abyssals = abyssals::load_cards(&db, scope, &roster).await; let saved_filters = assets::saved_filters(&db).await.unwrap_or_default(); let asset_tags = infra::tag_all_scoped(&db, TAG_SCOPE_ASSET).await.unwrap_or_default(); let tag_memberships = infra::membership_map(&db, ENTITY_TYPE_ASSET).await.unwrap_or_default();
Loaded { asset_tags, corporations, expand_containers, geo_tree, hangar_divisions, inventory, inventory_children, roster, saved_filters, tag_memberships, totals, values, nav, stockpiles, abyssals, }}
async fn load_hangar_divisions(db: &Database, corporations: &[RosterCorp]) -> HashMap<(i64, i64), String> { let mut names = HashMap::new(); for corp in corporations { let divisions = assets::hangar_divisions(db, corp.id).await.unwrap_or_default(); for division in divisions { if let Some(name) = division.name() && !name.is_empty() { names.insert((corp.id, division.division()), name.clone()); } } } names}
/// Reloads only the asset-tag registry and membership map after a modal write, so chips refresh without/// a full inventory refetch.async fn reload_asset_tags(db: Database) -> Message { let tags = infra::tag_all_scoped(&db, TAG_SCOPE_ASSET).await.unwrap_or_default(); let memberships = infra::membership_map(&db, ENTITY_TYPE_ASSET).await.unwrap_or_default(); Message::AssetTagsReloaded { memberships, tags, }}
/// The per-scope inventory load: the filtered page plus, when a filter is active, the ancestor/// containers that hold nested matches (so they can be auto-expanded to reveal those matches).#[derive(Default)]struct ScopeLoad { /// Container ids on the path to a match, shallow-to-deep. Empty when no filter is active. expand_containers: Vec<i64>, inventory: Vec<InventoryRow>, /// Pre-loaded children of each `expand_containers` entry, keyed by container `item_id`. inventory_children: HashMap<i64, Vec<InventoryRow>>, totals: InventoryTotals,}
async fn load_scope(db: &Database, owner: &Owner, view: &InventoryView) -> ScopeLoad { let me_id = match owner { Owner::Character(id) => Some(*id), Owner::Combined { .. } | Owner::Corporation(_) => None, }; let query = InventoryQuery { cursor: None, direction: view.sort_dir, filter: &view.filter, limit: view.limit, location_ids: &view.location_ids, me_id, reproc_yield: crate::config::reprocessing_yield_or_default(), sort: view.sort, };
let (totals, mut inventory) = match owner { Owner::Character(id) => { let totals = assets::inventory_totals_for_character(db, *id, &view.filter, &view.location_ids, me_id) .await .unwrap_or_default(); let inventory = assets::inventory_page_for_character(db, *id, &query) .await .unwrap_or_default(); (totals, inventory) } Owner::Combined { character_ids, corporation_ids, } => { let totals = assets::inventory_totals_for_combined( db, character_ids, corporation_ids, &view.filter, &view.location_ids, None, ) .await .unwrap_or_default(); let inventory = assets::inventory_page_for_combined(db, character_ids, corporation_ids, &query) .await .unwrap_or_default(); (totals, inventory) } Owner::Corporation(id) => { let totals = assets::inventory_totals_for_corporation(db, *id, &view.filter, &view.location_ids, None) .await .unwrap_or_default(); let inventory = assets::inventory_page_for_corporation(db, *id, &query) .await .unwrap_or_default(); (totals, inventory) } };
// No filter ⇒ normal browse; nothing nested to surface, so skip the ancestor work entirely. if view.filter.trim().is_empty() { return ScopeLoad { expand_containers: Vec::new(), inventory, inventory_children: HashMap::new(), totals, }; }
let (expand_containers, inventory_children, injected_roots) = load_match_ancestors(db, owner, &view.filter, me_id).await;
// The filtered page query only returns top-level matching rows (container_id IS NULL), so a // top-level ancestor container that does not itself match was dropped. Inject those root rows back // in so the chain has a visible parent to hang under, deduping against rows already present. let present: HashSet<i64> = inventory.iter().map(|row| row.item_id).collect(); for row in injected_roots { if !present.contains(&row.item_id) { inventory.push(row); } }
ScopeLoad { expand_containers, inventory, inventory_children, totals, }}
/// Resolves the set of container ids on the path from the top level down to any filter match,/// deduped across a combined scope's characters and corporations.async fn resolve_match_ancestors(db: &Database, owner: &Owner, filter: &str, me_id: Option<i64>) -> Vec<i64> { match owner { Owner::Character(id) => log_ancestors(assets::ancestors_of_match_for_character(db, *id, filter, me_id).await), Owner::Combined { character_ids, corporation_ids, } => { let mut ancestors = log_ancestors(assets::ancestors_of_match_for_characters(db, character_ids, filter, me_id).await); for corporation_id in corporation_ids { ancestors.extend(log_ancestors( assets::ancestors_of_match_for_corporation(db, *corporation_id, filter, me_id).await, )); } ancestors.sort_unstable(); ancestors.dedup(); ancestors } Owner::Corporation(id) => log_ancestors(assets::ancestors_of_match_for_corporation(db, *id, filter, me_id).await), }}
fn log_ancestors(result: Result<Vec<i64>, crate::store::Error>) -> Vec<i64> { result.unwrap_or_else(|error| { tracing::warn!(%error, "failed to resolve filter-match ancestors; nested matches may stay hidden"); Vec::new() })}
/// Computes the ancestor-container expansion for an active filter: the ordered set of container ids/// on the path to any match, their pre-loaded children, and the top-level ancestor rows to inject/// back into the page (the rows the filtered query dropped because they do not match themselves).async fn load_match_ancestors( db: &Database, owner: &Owner, filter: &str, me_id: Option<i64>,) -> (Vec<i64>, HashMap<i64, Vec<InventoryRow>>, Vec<InventoryRow>) { let reproc_yield = crate::config::reprocessing_yield_or_default();
let ancestors = resolve_match_ancestors(db, owner, filter, me_id).await;
if ancestors.is_empty() { return (Vec::new(), HashMap::new(), Vec::new()); }
// Fetch the ancestor rows themselves (ignoring the filter) so we can both inject the top-level // ones into the page and order the expansion shallow-to-deep. let ancestor_rows = rows_by_item_id(db, owner, &ancestors, reproc_yield).await; let injected_roots: Vec<InventoryRow> = ancestor_rows .iter() .filter(|row| row.container_id.is_none()) .cloned() .collect();
// Load every ancestor's children pruned to the page filter, keeping path containers (the ancestor // set itself) so deeper matches stay reachable while non-matching siblings are dropped. let child_filter = ChildFilter { filter, me_id, path_container_ids: &ancestors, reproc_yield, }; let mut inventory_children = HashMap::with_capacity(ancestors.len()); for &container_id in &ancestors { let children = load_container_children_for_owner(db, owner, container_id, &child_filter).await; inventory_children.insert(container_id, children); }
// Order expansion shallow-to-deep by container_id chain depth so a parent is always seeded before // its descendants; a row's depth field is authoritative for nesting order. let depth_of: HashMap<i64, i64> = ancestor_rows.iter().map(|row| (row.item_id, row.depth)).collect(); let mut expand_containers = ancestors; expand_containers.sort_by_key(|id| depth_of.get(id).copied().unwrap_or(0));
(expand_containers, inventory_children, injected_roots)}
/// Fetches inventory rows for an explicit `item_id` set within a scope, ignoring any filter.async fn rows_by_item_id(db: &Database, owner: &Owner, item_ids: &[i64], reproc_yield: f64) -> Vec<InventoryRow> { match owner { Owner::Character(id) => assets::rows_by_item_id_for_character(db, *id, item_ids, reproc_yield) .await .unwrap_or_default(), Owner::Combined { character_ids, corporation_ids, } => { let mut rows = assets::rows_by_item_id_for_characters(db, character_ids, item_ids, reproc_yield) .await .unwrap_or_default(); for corporation_id in corporation_ids { rows.extend( assets::rows_by_item_id_for_corporation(db, *corporation_id, item_ids, reproc_yield) .await .unwrap_or_default(), ); } rows } Owner::Corporation(id) => assets::rows_by_item_id_for_corporation(db, *id, item_ids, reproc_yield) .await .unwrap_or_default(), }}
/// Fetches a container's children for a scope pruned to the active filter, keeping the path/// containers carried in `child_filter` so deeper matches stay reachable (mirrors/// `load_container_children` but keyed on a resolved `Owner` so the ancestor loader can reuse it).async fn load_container_children_for_owner( db: &Database, owner: &Owner, container_id: i64, child_filter: &ChildFilter<'_>,) -> Vec<InventoryRow> { match owner { Owner::Character(id) => assets::children_render_filtered_for_character(db, *id, container_id, child_filter) .await .unwrap_or_default(), Owner::Combined { character_ids, corporation_ids, } => { let mut children = assets::children_render_filtered_for_characters(db, character_ids, container_id, child_filter) .await .unwrap_or_default(); for corporation_id in corporation_ids { children.extend( assets::children_render_filtered_for_corporation(db, *corporation_id, container_id, child_filter) .await .unwrap_or_default(), ); } children } Owner::Corporation(id) => assets::children_render_filtered_for_corporation(db, *id, container_id, child_filter) .await .unwrap_or_default(), }}
async fn load_inventory_page( db: &Database, scope: Scope, roster: &[RosterPilot], corporations: &[RosterCorp], view: &InventoryView, cursor: InventoryCursor,) -> Vec<InventoryRow> { let Some(owner) = resolve_scope_owner(scope, roster, corporations) else { return Vec::new(); }; let me_id = match &owner { Owner::Character(id) => Some(*id), Owner::Combined { .. } | Owner::Corporation(_) => None, }; let query = InventoryQuery { cursor: Some(cursor), direction: view.sort_dir, filter: &view.filter, limit: INVENTORY_PAGE_SIZE, location_ids: &view.location_ids, me_id, reproc_yield: crate::config::reprocessing_yield_or_default(), sort: view.sort, }; match &owner { Owner::Character(id) => assets::inventory_page_for_character(db, *id, &query).await, Owner::Combined { character_ids, corporation_ids, } => assets::inventory_page_for_combined(db, character_ids, corporation_ids, &query).await, Owner::Corporation(id) => assets::inventory_page_for_corporation(db, *id, &query).await, } .unwrap_or_default()}
async fn load_container_children( db: &Database, scope: Scope, roster: &[RosterPilot], corporations: &[RosterCorp], container_id: i64, filter: &str,) -> Vec<InventoryRow> { let Some(owner) = resolve_scope_owner(scope, roster, corporations) else { return Vec::new(); }; let reproc_yield = crate::config::reprocessing_yield_or_default(); // With no filter, manual expansion lists every child. While a filter is active it stays in view // mode: only matching children plus the path containers that lead to deeper matches. if filter.trim().is_empty() { return load_container_children_unfiltered(db, &owner, container_id, reproc_yield).await; } let me_id = match &owner { Owner::Character(id) => Some(*id), Owner::Combined { .. } | Owner::Corporation(_) => None, }; let path_container_ids = resolve_match_ancestors(db, &owner, filter, me_id).await; let child_filter = ChildFilter { filter, me_id, path_container_ids: &path_container_ids, reproc_yield, }; load_container_children_for_owner(db, &owner, container_id, &child_filter).await}
/// Fetches a container's full children for a scope, ignoring any filter (the no-filter manual-expand/// path; the filtered counterpart is `load_container_children_for_owner`).async fn load_container_children_unfiltered( db: &Database, owner: &Owner, container_id: i64, reproc_yield: f64,) -> Vec<InventoryRow> { match owner { Owner::Character(id) => assets::children_render_for_character(db, *id, container_id, reproc_yield) .await .unwrap_or_default(), Owner::Combined { character_ids, corporation_ids, } => { let mut children = assets::children_render_for_characters(db, character_ids, container_id, reproc_yield) .await .unwrap_or_default(); for corporation_id in corporation_ids { children.extend( assets::children_render_for_corporation(db, *corporation_id, container_id, reproc_yield) .await .unwrap_or_default(), ); } children } Owner::Corporation(id) => assets::children_render_for_corporation(db, *id, container_id, reproc_yield) .await .unwrap_or_default(), }}
async fn load_roster(db: &Database) -> Vec<RosterPilot> { let characters = character::all_owned(db).await.unwrap_or_default(); let credentials = infra::all(db).await.unwrap_or_default(); let scopes_by_id: std::collections::HashMap<i64, Option<String>> = credentials .into_iter() .filter(|cred| cred.owner_type() == OwnerType::Character) .map(|cred| (cred.owner_id(), cred.scopes().clone())) .collect();
let mut roster = Vec::with_capacity(characters.len()); for character in &characters { let corp = org::get_corporation(db, character.corporation_id()) .await .ok() .flatten() .map(|c| c.ticker().to_owned()) .unwrap_or_default(); let portrait = images::resolve( &images::default_store(), images::ImageKind::CharacterPortrait, character.id(), ); roster.push(RosterPilot { corp, granted_scopes: scopes_by_id.get(&character.id()).cloned().flatten(), id: character.id(), name: character.name().to_owned(), portrait, }); } roster}
async fn load_corporations(db: &Database) -> Vec<RosterCorp> { org::all_owned_corporations(db) .await .unwrap_or_default() .into_iter() .map(|corp| RosterCorp { id: corp.id(), logo: images::resolve(&images::default_store(), images::ImageKind::CorporationLogo, corp.id()), name: corp.name().to_owned(), ticker: corp.ticker().to_owned(), }) .collect()}
pub(super) fn owner_label(owner_id: i64, roster: &[RosterPilot], corporations: &[RosterCorp]) -> String { roster .iter() .find(|pilot| pilot.id == owner_id) .map(|pilot| pilot.name.clone()) .or_else(|| { corporations .iter() .find(|corp| corp.id == owner_id) .map(|corp| corp.name.clone()) }) .unwrap_or_else(|| t!("assets.inventory.owner_fallback", id => fmt_count(owner_id)).into_owned())}
#[cfg(test)]mod tests { use super::*;
mod enabled_tabs { use pretty_assertions::assert_eq;
use super::*;
fn only(sub: crate::config::SubFeature) -> crate::config::FeatureFlags { let mut flags = crate::config::FeatureFlags::default(); for candidate in crate::config::SubFeature::ALL { flags.set_sub_enabled(candidate, candidate == sub); } flags }
#[test] fn it_keeps_strip_order_with_every_sub_feature_enabled() { let tabs = enabled_tabs(&crate::config::FeatureFlags::default());
assert_eq!( tabs, vec![ Tab::Inventory, Tab::Abyssals, Tab::Stockpiles, Tab::Values, Tab::Tracker ] ); }
#[test] fn it_drops_a_disabled_sub_feature_from_the_strip() { let tabs = enabled_tabs(&only(crate::config::SubFeature::Values));
assert_eq!(tabs, vec![Tab::Values]); } }
mod tab { use super::*;
mod id { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_round_trips_every_tab_through_its_catalog_id() { assert_eq!(Tab::Abyssals.id(), "abyssals"); assert_eq!(Tab::Inventory.id(), "inventory"); assert_eq!(Tab::Stockpiles.id(), "stockpiles"); assert_eq!(Tab::Tracker.id(), "tracker"); assert_eq!(Tab::Values.id(), "values");
for tab in Tab::ORDER { assert_eq!(Tab::from_id(tab.id()), Some(tab)); } } } }
mod sync_features { use pretty_assertions::assert_eq;
use super::*;
fn without(sub: crate::config::SubFeature) -> crate::config::FeatureFlags { let mut flags = crate::config::FeatureFlags::default(); flags.set_sub_enabled(sub, false); flags }
#[test] fn it_redirects_off_a_disabled_active_tab_to_the_first_enabled_tab() { let mut state = State::new(crate::config::FeatureFlags::default()); state.tab = Tab::Inventory;
state.sync_features(without(crate::config::SubFeature::Inventory));
assert_eq!(state.tab, Tab::Abyssals); }
#[test] fn it_keeps_a_still_enabled_active_tab() { let mut state = State::new(crate::config::FeatureFlags::default()); state.tab = Tab::Tracker;
state.sync_features(without(crate::config::SubFeature::Inventory));
assert_eq!(state.tab, Tab::Tracker); } }
fn pilot(id: i64) -> RosterPilot { RosterPilot { corp: "TST".to_owned(), granted_scopes: None, id, name: format!("Pilot {id}"), portrait: images::ImageState::Stale { id, kind: images::ImageKind::CharacterPortrait, }, } }
fn corp(id: i64) -> RosterCorp { RosterCorp { id, logo: images::ImageState::Stale { id, kind: images::ImageKind::CorporationLogo, }, name: format!("Corp {id}"), ticker: "CRP".to_owned(), } }
mod effective_filter { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_passes_the_search_through_untouched_for_the_all_category() { assert_eq!(effective_filter(Category::All, "name:Rifter"), "name:Rifter"); assert_eq!(effective_filter(Category::All, ""), ""); }
#[test] fn it_prefixes_a_category_clause_when_a_category_is_active() { assert_eq!(effective_filter(Category::Ship, ""), "category:ship"); assert_eq!( effective_filter(Category::Module, "name:Rifter"), "category:module name:Rifter" ); } }
mod geo { use pretty_assertions::assert_eq;
use super::*; use crate::store::model::asset_query::{GeoConstellationNode, GeoLocationNode, GeoRegionNode, GeoSystemNode};
fn location(location_id: i64, value: f64) -> GeoLocationNode { GeoLocationNode { item_count: 1, location_id, location_label: Some(format!("Station {location_id}")), location_type: "station".to_owned(), value, } }
fn geo_tree() -> GeoTree { GeoTree { orphans: Vec::new(), regions: vec![GeoRegionNode { constellations: vec![GeoConstellationNode { constellation_id: 20, constellation_name: "Kimotoro".to_owned(), item_count: 6, systems: vec![ GeoSystemNode { item_count: 4, locations: vec![location(60_000_001, 100.0), location(60_000_002, 50.0)], security_status: Some(0.9), system_id: 30, system_name: "Jita".to_owned(), value: 150.0, }, GeoSystemNode { item_count: 2, locations: vec![location(60_000_003, 25.0)], security_status: Some(0.9), system_id: 31, system_name: "Perimeter".to_owned(), value: 25.0, }, ], value: 175.0, }], item_count: 6, region_id: 10, region_name: "The Forge".to_owned(), value: 175.0, }], } }
fn sorted(mut ids: Vec<i64>) -> Vec<i64> { ids.sort_unstable(); ids }
#[tokio::test] async fn it_records_a_geo_selection() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_geo_tree_for_test(geo_tree());
let _ = update(&mut state, Message::GeoNodeSelected(GeoSelection::System(30)), &db);
assert_eq!(state.geo_selected(), GeoSelection::System(30)); }
#[tokio::test] async fn it_resets_the_geo_selection_and_collapse_state_when_the_scope_changes() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.geo_selected = GeoSelection::System(30); state.geo_expanded.insert(GeoNodeKey::Region(10));
let _ = update(&mut state, Message::ScopeSelected(Scope::Character(7)), &db);
assert_eq!(state.geo_selected(), GeoSelection::All); assert!( state.geo_is_collapsed(GeoNodeKey::Region(10)), "the scope change re-collapses every group" ); }
#[test] fn it_resolves_an_unknown_selection_to_no_location_ids() { let tree = geo_tree();
assert!(location_ids_for_selection(&tree, GeoSelection::Region(999)).is_empty()); assert!(location_ids_for_selection(&tree, GeoSelection::System(999)).is_empty()); }
#[test] fn it_resolves_each_selection_level_to_its_location_ids() { let tree = geo_tree();
assert!(location_ids_for_selection(&tree, GeoSelection::All).is_empty()); assert_eq!( location_ids_for_selection(&tree, GeoSelection::Location(60_000_002)), [60_000_002] ); assert_eq!( sorted(location_ids_for_selection(&tree, GeoSelection::System(30))), [60_000_001, 60_000_002] ); assert_eq!( sorted(location_ids_for_selection(&tree, GeoSelection::Constellation(20))), [60_000_001, 60_000_002, 60_000_003] ); assert_eq!( sorted(location_ids_for_selection(&tree, GeoSelection::Region(10))), [60_000_001, 60_000_002, 60_000_003] ); }
#[tokio::test] async fn it_toggles_a_group_expanded_then_collapsed_from_a_collapsed_default() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); let key = GeoNodeKey::Region(10); assert!(state.geo_is_collapsed(key), "groups render collapsed by default");
let _ = update(&mut state, Message::GeoNodeToggled(key), &db); assert!(!state.geo_is_collapsed(key));
let _ = update(&mut state, Message::GeoNodeToggled(key), &db); assert!(state.geo_is_collapsed(key)); } }
mod load { use super::*;
#[tokio::test] async fn it_loads_an_empty_portfolio_for_a_fresh_db() { let db = crate::store::open_test().await.unwrap();
let loaded = load_assets(db, Scope::All, InventoryView::default()).await;
assert!(loaded.roster.is_empty()); assert_eq!(loaded.totals, InventoryTotals::default()); } }
/// Production-path coverage for the filter auto-expand: a filtered load runs through the real /// `load_assets` -> `load_scope` -> `load_match_ancestors` chain, and the resulting `Loaded` drives /// `apply_loaded`/`merge_loaded` exactly as it would in the app. mod filter_expansion { use pretty_assertions::assert_eq;
use super::*; use crate::store::{ model::{Alliance, Bloodline, Character, CharacterAsset, Corporation, Gender, ItemCategory, ItemGroup, Race}, repo::{ character::insert_with_org, sde::{upsert_item_category, upsert_item_group}, }, };
const PILOT: i64 = 42;
async fn seed_pilot(db: &Database) { let corp_id = 90_000_001; let alliance_id = 99_000_001; let alliance = Alliance::new(alliance_id, corp_id, PILOT, "2003-01-01", "Test Alliance", "TST"); let race = Race::new(2, alliance_id, "A race.", "Caldari"); let mut corp = Corporation::new(corp_id, "Test Corp", "TSC"); corp.set_ceo_id(PILOT); corp.set_creator_id(PILOT); corp.set_member_count(1); corp.set_tax_rate(0.0); let bloodline = Bloodline::new(1, corp_id, 2, 3, "A bloodline.", 4, 5, "Civire", 4, 4); let character = Character::new(PILOT, 1, corp_id, 2, "2003-05-12", Gender::Male, "Pilot"); insert_with_org(db, &character, &bloodline, &race, &corp, Some(&alliance), None) .await .unwrap(); }
async fn seed_item_type(db: &Database, type_id: i64, name: &str, group_id: i64, group_name: &str, cat: &str) { let category = ItemCategory { id: group_id * 10, icon_id: None, name: cat.to_owned(), published: true, }; let group = ItemGroup { category_id: category.id(), icon_id: None, id: group_id, name: group_name.to_owned(), published: true, }; upsert_item_category(db, &category).await.unwrap(); upsert_item_group(db, &group).await.unwrap(); sqlx::query( "INSERT INTO item_types (id, group_id, description, name, published, icon_id, packaged_volume, volume) \ VALUES (?, ?, ?, ?, 1, ?, 2.5, 27289.0)", ) .bind(type_id) .bind(group_id) .bind("Test item") .bind(name) .bind(type_id + 1000) .execute(db.writer()) .await .unwrap(); }
fn asset(item_id: i64, container_id: Option<i64>, type_id: i64, is_container: bool, depth: i64) -> CharacterAsset { CharacterAsset { character_id: PILOT, container_id, depth, is_active_ship: false, is_blueprint_copy: None, is_container, is_singleton: false, item_id, location_flag: "Hangar".to_owned(), location_id: 60_003_760, location_type: "station".to_owned(), name: None, quantity: 1, type_id, } }
// Seeds a character whose holdings are: // - container 100 (root) -> container 101 (sub) -> Tritanium 102 (the nested match) // - container 101 also holds Rifter 103 (a ship sibling of the match; never matches) // - container 100 also holds Rifter 104 (a ship sibling of the path container; never matches) // - container 200 (root) -> Rifter 201 (a ship; never matches `category:material`) // // Neither root container matches `category:material`, so the filtered page query drops both. async fn seed_nested_holdings(db: &Database) { seed_pilot(db).await; seed_item_type(db, 24, "Tritanium", 18, "Mineral", "Mineral").await; seed_item_type(db, 587, "Rifter", 25, "Frigate", "Ship").await; let holdings = [ asset(100, None, 587, true, 0), asset(101, Some(100), 587, true, 1), asset(102, Some(101), 24, false, 2), asset(103, Some(101), 587, false, 2), asset(104, Some(100), 587, false, 1), asset(200, None, 587, true, 0), asset(201, Some(200), 587, false, 1), ]; assets::replace_for_character(db, PILOT, &holdings).await.unwrap(); }
fn filtered_view() -> InventoryView { InventoryView { filter: "category:material".to_owned(), ..InventoryView::default() } }
#[tokio::test] async fn it_auto_expands_the_ancestor_chain_holding_a_nested_match() { let db = crate::store::open_test().await.unwrap(); seed_nested_holdings(&db).await;
let loaded = load_assets(db, Scope::Character(PILOT), filtered_view()).await;
// The whole path to the deep match is expanded, shallow-to-deep. assert_eq!(loaded.expand_containers, [100, 101]); // The dropped top-level ancestor row was injected back into the page so the chain has a parent. assert!( loaded.inventory.iter().any(|row| row.item_id == 100), "the root ancestor row is re-injected into the page" ); // The nested match is reachable: container 101 is a loaded child of 100, and the match 102 is a // loaded child of 101. let sub_in_root = loaded.inventory_children[&100].iter().any(|row| row.item_id == 101); let match_in_sub = loaded.inventory_children[&101].iter().any(|row| row.item_id == 102); assert!(sub_in_root, "the sub-container is a loaded child of the root"); assert!(match_in_sub, "the match is a loaded child of the sub-container"); }
#[tokio::test] async fn it_prunes_non_matching_siblings_at_every_level_of_the_chain() { let db = crate::store::open_test().await.unwrap(); seed_nested_holdings(&db).await;
let loaded = load_assets(db, Scope::Character(PILOT), filtered_view()).await;
// The root keeps only the path container 101; its non-matching ship sibling 104 is pruned. let root_children: Vec<i64> = loaded.inventory_children[&100].iter().map(|row| row.item_id).collect(); assert_eq!( root_children, [101], "the root lists only the path container, not its ship sibling" );
// The sub-container keeps only the match 102; its non-matching ship sibling 103 is pruned. let sub_children: Vec<i64> = loaded.inventory_children[&101].iter().map(|row| row.item_id).collect(); assert_eq!( sub_children, [102], "the sub-container lists only the match, not its ship sibling" ); }
#[tokio::test] async fn it_leaves_a_container_without_a_match_collapsed() { let db = crate::store::open_test().await.unwrap(); seed_nested_holdings(&db).await;
let loaded = load_assets(db, Scope::Character(PILOT), filtered_view()).await;
// Container 200 holds only a ship, so it must not be auto-expanded or its children pre-loaded. assert!( !loaded.expand_containers.contains(&200), "a container with no matching descendant stays collapsed" ); assert!(!loaded.inventory_children.contains_key(&200)); }
#[tokio::test] async fn it_seeds_state_expansion_from_a_filtered_load() { let db = crate::store::open_test().await.unwrap(); seed_nested_holdings(&db).await; let loaded = load_assets(db, Scope::Character(PILOT), filtered_view()).await;
let mut state = State::new(crate::config::FeatureFlags::default()); apply_loaded(&mut state, loaded);
assert!(state.container_is_open(100), "the root ancestor is auto-expanded"); assert!(state.container_is_open(101), "the sub-container is auto-expanded"); assert!( !state.container_is_open(200), "the non-matching container stays collapsed" ); assert_eq!( state .container_children_of(101) .map(|rows| rows.iter().any(|row| row.item_id == 102)), Some(true), "the nested match is reachable under its auto-expanded parent" ); }
#[tokio::test] async fn it_resets_expansion_when_the_filter_clears() { let db = crate::store::open_test().await.unwrap(); seed_nested_holdings(&db).await;
// First load with a filter active, seeding filter-driven expansions. let filtered = load_assets(db.clone(), Scope::Character(PILOT), filtered_view()).await; let mut state = State::new(crate::config::FeatureFlags::default()); apply_loaded(&mut state, filtered); assert!(state.container_is_open(100), "the filter expanded the ancestor chain");
// Clearing the filter reloads with no expansion set; apply must collapse back to normal browse. let unfiltered = load_assets(db, Scope::Character(PILOT), InventoryView::default()).await; assert!(unfiltered.expand_containers.is_empty()); apply_loaded(&mut state, unfiltered);
assert!( !state.container_is_open(100), "clearing the filter collapses the auto-expansion" ); assert!(!state.container_is_open(101)); } }
mod mark_dirty { use super::*;
#[test] fn it_ignores_an_unrelated_kind() { let mut state = State::new(crate::config::FeatureFlags::default());
state.mark_dirty(JobKind::CharacterWallet);
assert!(!state.is_dirty()); }
#[test] fn it_marks_the_assets_dirty_for_an_asset_sync() { let mut state = State::new(crate::config::FeatureFlags::default());
state.mark_dirty(JobKind::AssetSync);
assert!(state.is_dirty()); } }
mod pagination { use pretty_assertions::assert_eq;
use super::*;
fn inv_row(item_id: i64, is_container: bool) -> InventoryRow { InventoryRow { category: "ship".to_owned(), container_id: None, depth: 0, group_name: "Frigate".to_owned(), is_active_ship: false, is_blueprint_copy: None, is_container, item_id, location_flag: "Hangar".to_owned(), location_id: 60_003_760, location_label: Some("Jita IV - Moon 4".to_owned()), name: None, owner_id: 7, quantity: 1, reproc_value: 0.0, row_volume: 10.0, type_icon: images::IconResolution::Missing, type_id: 587, type_name: "Rifter".to_owned(), unit_price: 100.0, value: 100.0, } }
#[tokio::test] async fn it_appends_a_loaded_page_and_clears_has_more_for_a_short_page() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory = vec![inv_row(100, false)]; state.inventory_has_more = true; state.inventory_loading = true;
let epoch = state.inventory_page_epoch.current(); let _ = update( &mut state, Message::InventoryPageLoaded { epoch, rows: vec![inv_row(101, false)], }, &db, );
assert_eq!( state.inventory.iter().map(|r| r.item_id).collect::<Vec<_>>(), [100, 101] ); assert!( !state.inventory_has_more, "a page shorter than the page size is the last page" ); assert!(!state.inventory_loading); }
#[tokio::test] async fn it_drops_an_inventory_page_captured_before_the_list_was_replaced() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory = vec![inv_row(100, false)]; state.inventory_has_more = true; state.inventory_loading = true;
// The user scrolled (capturing the current epoch) and then filtered/switched scope, which // replaces the list and bumps the epoch. let stale_epoch = state.inventory_page_epoch.current(); state.inventory_page_epoch.next(); state.inventory = vec![inv_row(200, false)];
let _ = update( &mut state, Message::InventoryPageLoaded { epoch: stale_epoch, rows: vec![inv_row(101, false)], }, &db, );
assert_eq!( state.inventory.iter().map(|r| r.item_id).collect::<Vec<_>>(), [200], "a page from the superseded query must not append foreign rows" ); }
#[tokio::test] async fn it_drops_expansions_for_items_absent_from_fresh_sync_data() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_inventory_children_for_test(100, vec![inv_row(101, false)]); let generation = state.search_generation;
let _ = update( &mut state, Message::SyncReloaded { generation, loaded: Box::new(Loaded { inventory: vec![inv_row(200, false)], ..Loaded::default() }), }, &db, );
assert!( !state.container_is_open(100), "an expanded container that vanished from fresh data is dropped" ); assert!(state.container_children_of(100).is_none()); }
#[tokio::test] async fn it_expands_a_container_loads_children_then_collapse_drops_them() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.active = Scope::Character(7);
let _ = update(&mut state, Message::ContainerToggled(100), &db); assert!(state.container_is_open(100));
let _ = update( &mut state, Message::ContainerChildrenLoaded(100, vec![inv_row(101, false)]), &db, ); assert_eq!(state.container_children_of(100).map(<[_]>::len), Some(1));
let _ = update(&mut state, Message::ContainerToggled(100), &db); assert!(!state.container_is_open(100)); assert!( state.container_children_of(100).is_none(), "collapse drops the loaded children" ); }
#[tokio::test] async fn it_ignores_a_stale_sync_reload_after_the_search_advanced() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); let stale_generation = state.search_generation; state.search_generation = state.search_generation.wrapping_add(1); state.totals.items = 3;
let _ = update( &mut state, Message::SyncReloaded { generation: stale_generation, loaded: Box::new(Loaded { totals: InventoryTotals { items: 99, locations: 1, value: 0.0, volume: 0.0, }, ..Loaded::default() }), }, &db, );
assert_eq!(state.totals.items, 3, "a stale sync reload is discarded"); }
#[tokio::test] async fn it_ignores_scroll_below_the_threshold() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory = vec![inv_row(100, false)]; state.inventory_has_more = true;
let _ = update( &mut state, Message::InventoryScrolled { absolute: 0.0, relative: 0.5, }, &db, );
assert!(!state.inventory_loading); }
#[tokio::test] async fn it_ignores_scroll_when_there_are_no_more_pages() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory = vec![inv_row(100, false)]; state.inventory_has_more = false;
let _ = update( &mut state, Message::InventoryScrolled { absolute: 0.0, relative: 0.95, }, &db, );
assert!(!state.inventory_loading, "no load is started when the set is exhausted"); }
#[tokio::test] async fn it_loads_an_empty_inventory_page_for_a_character_scope() { let db = crate::store::open_test().await.unwrap(); let roster = vec![pilot(7)]; let view = InventoryView::default(); let cursor = inv_row(100, false).cursor(view.sort);
let rows = load_inventory_page(&db, Scope::Character(7), &roster, &[], &view, cursor).await;
assert!(rows.is_empty()); }
#[tokio::test] async fn it_preserves_interaction_state_on_a_sync_triggered_reload() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.search = "name:Rifter".to_owned(); state.abyssal_picker_open = true; state.set_inventory_children_for_test(200, vec![inv_row(201, false)]); let generation = state.search_generation;
let _ = update( &mut state, Message::SyncReloaded { generation, loaded: Box::new(Loaded { inventory: vec![inv_row(200, true), inv_row(300, false)], totals: InventoryTotals { items: 9, locations: 1, value: 0.0, volume: 0.0, }, ..Loaded::default() }), }, &db, );
assert_eq!(state.totals.items, 9, "a sync reload refreshes the underlying totals"); assert_eq!(state.search, "name:Rifter", "the in-progress filter text survives"); assert!(state.abyssal_picker_open, "an open picker survives a sync reload"); assert!( state.container_is_open(200), "an expanded container still present in fresh data stays open" ); assert_eq!(state.container_children_of(200).map(<[_]>::len), Some(1)); }
#[tokio::test] async fn it_resets_pagination_and_expansion_on_reload() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_inventory_children_for_test(100, vec![inv_row(101, false)]);
let _ = update( &mut state, Message::Loaded(Box::new(Loaded { inventory: vec![inv_row(200, false)], ..Loaded::default() })), &db, );
assert_eq!(state.inventory.iter().map(|r| r.item_id).collect::<Vec<_>>(), [200]); assert!( state.expanded_containers.is_empty(), "a reload clears stale container expansions" ); assert!(state.container_children_of(100).is_none()); assert!(!state.inventory_has_more, "a short first page leaves no more to load"); }
#[tokio::test] async fn it_starts_a_load_when_scrolling_past_the_threshold_with_more_pages() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.active = Scope::Character(7); state.inventory = vec![inv_row(100, false)]; state.inventory_has_more = true;
let _ = update( &mut state, Message::InventoryScrolled { absolute: 0.0, relative: 0.9, }, &db, );
assert!( state.inventory_loading, "crossing the threshold with more pages starts a fetch" ); }
#[tokio::test] async fn it_tracks_the_absolute_scroll_offset_for_windowing() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory = vec![inv_row(100, false)];
let _ = update( &mut state, Message::InventoryScrolled { absolute: 1_234.0, relative: 0.5, }, &db, );
assert_eq!( state.inventory_scroll_offset(), 1_234.0, "the pixel offset is stored so the virtual list can window the body" ); }
#[tokio::test] async fn it_yields_no_rows_when_the_scope_resolves_to_no_owner() { let db = crate::store::open_test().await.unwrap(); let view = InventoryView::default(); let cursor = inv_row(100, false).cursor(view.sort);
let rows = load_inventory_page(&db, Scope::Corporation(7), &[], &[], &view, cursor).await;
assert!(rows.is_empty()); }
#[tokio::test] async fn it_snaps_the_inventory_to_the_top_when_a_selection_reloads() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory_scroll_offset = 900.0;
let _ = update(&mut state, Message::GeoNodeSelected(GeoSelection::System(30)), &db);
assert_eq!( state.inventory_scroll_offset(), 0.0, "an explicit selection returns the list to the top" ); }
#[tokio::test] async fn it_snaps_the_inventory_to_the_top_when_a_search_is_submitted() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.search = "tritanium".to_owned(); state.inventory_scroll_offset = 640.0;
let _ = update(&mut state, Message::SearchSubmitted, &db);
assert_eq!( state.inventory_scroll_offset(), 0.0, "pressing Enter on a search returns the list to the top" ); }
#[tokio::test] async fn it_does_not_snap_the_inventory_while_live_search_typing() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory_scroll_offset = 720.0;
let _ = update(&mut state, Message::SearchChanged("trit".to_owned()), &db);
assert_eq!( state.inventory_scroll_offset(), 720.0, "live-search typing must not jump the view on every keystroke" ); }
#[tokio::test] async fn it_keeps_the_inventory_scroll_offset_when_a_row_is_right_clicked() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory = vec![inv_row(100, false)]; state.inventory_scroll_offset = 512.0;
let _ = update(&mut state, Message::InventoryRowRightPressed(100), &db);
assert_eq!( state.inventory_scroll_offset(), 512.0, "opening the context menu must not move the scroll position" ); } }
mod panes { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_defaults_both_pane_widths_when_the_store_is_empty() { let state = State::new(crate::config::FeatureFlags::default()).with_restored_panes(&UiState::default());
assert_eq!(state.pane(Pane::Sidebar).width(), SIDEBAR_DEFAULT_WIDTH); assert_eq!(state.pane(Pane::AbyssalsFilter).width(), ABYSSALS_FILTER_DEFAULT_WIDTH); }
#[test] fn it_does_not_listen_for_drag_events_while_no_pane_is_active() { let state = State::new(crate::config::FeatureFlags::default());
let _sub: iced::Subscription<Message> = subscription(&state); }
#[test] fn it_listens_for_drag_events_while_the_abyssals_filter_pane_is_active() { let mut state = State::new(crate::config::FeatureFlags::default()); state.abyssals_filter.start();
let _sub: iced::Subscription<Message> = subscription(&state); }
#[test] fn it_persists_the_settled_width_under_the_matching_pane_key() { let mut state = State::new(crate::config::FeatureFlags::default()); assert_eq!(state.pane_mut(Pane::Sidebar).1, SIDEBAR_PANE_KEY); assert_eq!(state.pane_mut(Pane::AbyssalsFilter).1, ABYSSALS_FILTER_PANE_KEY); }
#[test] fn it_reports_the_active_drag_pane_only_while_dragging() { let mut state = State::new(crate::config::FeatureFlags::default()); assert!(state.active_drag().is_none());
state.sidebar.start(); assert_eq!(state.active_drag(), Some(Pane::Sidebar));
state.sidebar.end(); state.abyssals_filter.start(); assert_eq!(state.active_drag(), Some(Pane::AbyssalsFilter)); }
#[tokio::test] async fn it_resizes_the_sidebar_during_a_drag_and_settles_its_width() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::PaneDragStart(Pane::Sidebar), &db); let _ = update(&mut state, Message::PaneDrag(500.0), &db); let _ = update(&mut state, Message::PaneDrag(560.0), &db); assert_eq!(state.pane(Pane::Sidebar).width(), SIDEBAR_DEFAULT_WIDTH + 60.0); assert!(state.pane(Pane::Sidebar).is_active());
let _task = update(&mut state, Message::PaneDragEnd, &db); assert!(!state.pane(Pane::Sidebar).is_active()); assert_eq!(state.pane(Pane::Sidebar).width(), SIDEBAR_DEFAULT_WIDTH + 60.0); }
#[test] fn it_restores_both_pane_widths_from_the_keyed_store() { let mut ui = UiState::default(); ui.panes.insert(SIDEBAR_PANE_KEY.to_owned(), 360.0); ui.panes.insert(ABYSSALS_FILTER_PANE_KEY.to_owned(), 200.0);
let state = State::new(crate::config::FeatureFlags::default()).with_restored_panes(&ui);
// Legacy pixel widths are stored as ratios and multiplied back by the host // width, so the roundtrip lands within a rounding error of the original. assert!((state.pane(Pane::Sidebar).width() - 360.0).abs() < 0.01); assert!((state.pane(Pane::AbyssalsFilter).width() - 200.0).abs() < 0.01); }
#[tokio::test] async fn it_routes_a_drag_solely_to_the_active_abyssals_filter_pane() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::PaneDragStart(Pane::AbyssalsFilter), &db); let _ = update(&mut state, Message::PaneDrag(400.0), &db); let _ = update(&mut state, Message::PaneDrag(370.0), &db); assert_eq!( state.pane(Pane::AbyssalsFilter).width(), ABYSSALS_FILTER_DEFAULT_WIDTH - 30.0 ); assert_eq!(state.pane(Pane::Sidebar).width(), SIDEBAR_DEFAULT_WIDTH);
let _task = update(&mut state, Message::PaneDragEnd, &db); assert!(!state.pane(Pane::AbyssalsFilter).is_active()); } }
mod resolve_scope_owner { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_aggregates_the_all_scope_across_every_owned_character_and_corporation() { assert_eq!( resolve_scope_owner(Scope::All, &[pilot(7), pilot(9)], &[corp(98)]), Some(Owner::Combined { character_ids: vec![7, 9], corporation_ids: vec![98], }) ); }
#[test] fn it_resolves_a_character_scope_to_that_character() { assert_eq!( resolve_scope_owner(Scope::Character(7), &[], &[]), Some(Owner::Character(7)) ); }
#[test] fn it_resolves_a_known_corporation_scope_to_that_corporation() { assert_eq!( resolve_scope_owner(Scope::Corporation(98), &[], &[corp(98)]), Some(Owner::Corporation(98)) ); }
#[test] fn it_resolves_an_unknown_corporation_scope_to_none() { assert_eq!(resolve_scope_owner(Scope::Corporation(404), &[], &[corp(98)]), None); }
#[test] fn it_resolves_the_all_scope_with_no_owned_characters_to_the_corporations_alone() { assert_eq!( resolve_scope_owner(Scope::All, &[], &[corp(98)]), Some(Owner::Combined { character_ids: vec![], corporation_ids: vec![98], }) ); } }
mod saved_filter { use pretty_assertions::assert_eq;
use super::*;
fn filter(id: i64, name: &str, query: &str, category: Option<&str>) -> SavedAssetFilter { SavedAssetFilter { category: category.map(str::to_owned), id, name: name.to_owned(), query: query.to_owned(), } }
#[tokio::test] async fn a_created_filter_is_recorded_selected_and_clears_the_geo_selection() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.geo_selected = GeoSelection::System(30);
let _ = update( &mut state, Message::SavedFilterCreated(Some(5), vec![filter(5, "Ships", "category:ship", Some("ship"))]), &db, );
assert_eq!(state.saved_filters().len(), 1); assert_eq!(state.saved_filter_active(), Some(5)); assert_eq!(state.geo_selected(), GeoSelection::All); }
#[tokio::test] async fn a_reloaded_list_replaces_the_saved_filters() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filters = vec![filter(1, "Old", "", None)];
let _ = update( &mut state, Message::SavedFiltersReloaded(vec![filter(2, "New", "name:Rifter", None)]), &db, );
assert_eq!(state.saved_filters().len(), 1); assert_eq!(state.saved_filters()[0].id(), 2); }
#[tokio::test] async fn can_save_is_gated_on_a_query_or_a_non_all_category() { let mut state = State::new(crate::config::FeatureFlags::default()); assert!(!state.can_save_filter());
state.search = "tritanium".to_owned(); assert!(state.can_save_filter());
state.search = String::new(); state.category = Category::Module; assert!(state.can_save_filter()); }
#[tokio::test] async fn confirming_closes_the_modal() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filter_modal_open = true; state.saved_filter_draft_name = "Ships".to_owned(); state.category = Category::Ship;
let _ = update(&mut state, Message::SaveFilterConfirmed, &db);
assert!(!state.saved_filter_modal_open()); assert_eq!(state.saved_filter_draft_name(), ""); }
#[tokio::test] async fn confirming_with_an_empty_name_keeps_the_modal_open() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filter_modal_open = true; state.saved_filter_draft_name = " ".to_owned(); state.search = "tritanium".to_owned();
let _ = update(&mut state, Message::SaveFilterConfirmed, &db);
assert!(state.saved_filter_modal_open()); }
#[tokio::test] async fn deleting_the_active_filter_clears_the_selection() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filters = vec![filter(5, "Ships", "category:ship", Some("ship"))]; state.saved_filter_active = Some(5);
let _ = update(&mut state, Message::SavedFilterDeleted(5), &db);
assert_eq!(state.saved_filter_active(), None); }
#[tokio::test] async fn it_cancels_the_modal() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filter_modal_open = true; state.saved_filter_draft_name = "Jita".to_owned();
let _ = update(&mut state, Message::SaveFilterCancelled, &db);
assert!(!state.saved_filter_modal_open()); assert_eq!(state.saved_filter_draft_name(), ""); }
#[tokio::test] async fn it_opens_and_clears_the_modal() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filter_draft_name = "stale".to_owned();
let _ = update(&mut state, Message::SaveFilterOpened, &db);
assert!(state.saved_filter_modal_open()); assert_eq!(state.saved_filter_draft_name(), ""); }
#[tokio::test] async fn it_records_the_draft_name() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update( &mut state, Message::SaveFilterNameChanged("Jita modules".to_owned()), &db, );
assert_eq!(state.saved_filter_draft_name(), "Jita modules"); }
#[tokio::test] async fn re_selecting_the_active_filter_clears_it() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filters = vec![filter(5, "Modules", "name:Rifter", Some("module"))]; state.saved_filter_active = Some(5); state.search = "name:Rifter".to_owned(); state.category = Category::Module;
let _ = update(&mut state, Message::SavedFilterSelected(5), &db);
assert_eq!(state.saved_filter_active(), None); assert_eq!(state.search(), ""); assert_eq!(state.category(), Category::All); }
#[tokio::test] async fn right_pressing_a_filter_opens_its_context_menu_at_the_cursor() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filters = vec![filter(5, "Ships", "category:ship", Some("ship"))]; state.sidebar_cursor = Some(iced::Point::new(12.0, 34.0));
let _ = update(&mut state, Message::SavedFilterRightPressed(5), &db);
let menu = state.saved_filter_context_menu().expect("a context menu"); assert_eq!(menu.id, 5); assert_eq!(menu.name, "Ships"); }
#[tokio::test] async fn selecting_a_filter_restores_its_query_and_category_and_clears_the_location() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filters = vec![filter(5, "Modules", "name:Rifter", Some("module"))]; state.geo_selected = GeoSelection::System(30);
let _ = update(&mut state, Message::SavedFilterSelected(5), &db);
assert_eq!(state.saved_filter_active(), Some(5)); assert_eq!(state.search(), "name:Rifter"); assert_eq!(state.category(), Category::Module); assert_eq!(state.geo_selected(), GeoSelection::All); }
#[tokio::test] async fn selecting_a_location_clears_the_active_saved_filter() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.saved_filter_active = Some(5);
let _ = update(&mut state, Message::GeoNodeSelected(GeoSelection::System(30)), &db);
assert_eq!(state.saved_filter_active(), None); }
#[tokio::test] async fn the_capture_preview_prefers_the_query_then_falls_back_to_the_category() { let mut state = State::new(crate::config::FeatureFlags::default()); state.search = " name:Rifter ".to_owned(); assert_eq!(state.save_filter_capture(), "name:Rifter");
state.search = String::new(); state.category = Category::Ship; assert_eq!(state.save_filter_capture(), "category:ship");
state.category = Category::All; assert_eq!(state.save_filter_capture(), ""); } }
mod tab_scope_gate { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_does_not_gate_a_character_with_the_asset_scopes() { let granted = crate::features::shell::registry::descriptor(crate::config::Feature::AssetTracking) .scopes .join(" "); let mut granted_pilot = pilot(1); granted_pilot.granted_scopes = Some(granted); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_picker_for_test(Scope::Character(1), vec![granted_pilot], Vec::new());
assert!(state.tab_scope_gate().is_none()); }
#[test] fn it_does_not_gate_the_all_or_corporation_scopes() { let mut state = State::new(crate::config::FeatureFlags::default()); state.set_picker_for_test(Scope::All, vec![pilot(1)], Vec::new());
assert!(state.tab_scope_gate().is_none());
state.set_picker_for_test(Scope::Corporation(99), vec![pilot(1)], Vec::new());
assert!(state.tab_scope_gate().is_none()); }
#[test] fn it_gates_a_character_scope_missing_the_asset_scopes() { let mut state = State::new(crate::config::FeatureFlags::default()); state.set_picker_for_test(Scope::Character(1), vec![pilot(1)], Vec::new());
let gate = state.tab_scope_gate().expect("missing scope should gate");
assert_eq!(gate.0, 1); assert!(!gate.2.is_empty()); } }
mod stale_images { use pretty_assertions::assert_eq;
use super::*;
fn fresh_pilot(id: i64) -> RosterPilot { RosterPilot { corp: "TST".to_owned(), granted_scopes: None, id, name: format!("Pilot {id}"), portrait: images::ImageState::Fresh(std::path::PathBuf::from(format!("/cache/{id}.jpg"))), } }
#[test] fn it_collects_stale_portraits_logos_and_abyssal_cards() { let mut state = State::new(crate::config::FeatureFlags::default()); state.set_picker_for_test(Scope::All, vec![pilot(7), fresh_pilot(9)], vec![corp(98)]); state.set_abyssals_for_test( vec![abyssals::AbyssalCard { character_id: 11, estimate: None, group_type_id: 2410, item_id: 1, location: String::new(), module_name: "Module".to_owned(), owner_name: "Vex".to_owned(), portrait: images::ImageState::Stale { id: 11, kind: images::ImageKind::CharacterPortrait, }, price_unavailable: false, stats: Vec::new(), tier_label: "Gravid".to_owned(), type_icon: images::IconResolution::Missing, }], Vec::new(), abyssals::Filters::default(), false, );
assert_eq!( state.stale_images(), vec![ (images::ImageKind::CharacterPortrait, 7), (images::ImageKind::CorporationLogo, 98), (images::ImageKind::CharacterPortrait, 11), ] ); }
#[test] fn it_is_empty_when_every_model_is_fresh() { let mut state = State::new(crate::config::FeatureFlags::default()); state.set_picker_for_test(Scope::All, vec![fresh_pilot(7)], Vec::new());
assert_eq!(state.stale_images(), Vec::new()); } }
mod stockpile { use pretty_assertions::assert_eq;
use super::*;
fn card(id: i64, name: &str) -> stockpiles::StockpileCard { stockpiles::StockpileCard { character_scope: None, fill_isk: 0.0, id, items: vec![], location_id: None, location_name: None, name: name.to_owned(), overall_pct: 0.0, scope_pilots: 0, target_isk: 0.0, } }
#[tokio::test] async fn deleting_spawns_a_reload_and_reloaded_cards_replace_state() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.stockpiles = vec![card(1, "Old")];
let _task = update(&mut state, Message::StockpileDeleted(1), &db);
let _ = update(&mut state, Message::StockpilesReloaded(vec![card(2, "New")]), &db); assert_eq!(state.stockpiles, vec![card(2, "New")]); }
#[tokio::test] async fn it_exposes_a_card_by_id_for_seeding_an_edit_window() { let mut state = State::new(crate::config::FeatureFlags::default()); state.stockpiles = vec![card(7, "Ammo")];
assert_eq!(state.stockpile_card(7).map(|card| card.name.as_str()), Some("Ammo")); assert!(state.stockpile_card(404).is_none()); }
#[tokio::test] async fn it_ignores_a_multibuy_copy_for_an_unknown_card() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::StockpileMultibuyExportCopied(404), &db);
assert!(!state.stockpile_multibuy_copied); }
#[tokio::test] async fn it_ignores_a_right_press_without_a_cursor_anchor() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.stockpiles = vec![card(7, "Ammo")];
let _ = update(&mut state, Message::StockpileCardRightPressed(7), &db);
assert!(state.stockpile_context_menu.is_none()); }
#[tokio::test] async fn it_opens_a_context_menu_at_the_cursor_then_closes_it() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.stockpiles = vec![card(7, "Ammo")];
let _ = update( &mut state, Message::StockpileCursorMoved(iced::Point::new(20.0, 40.0)), &db, ); let _ = update(&mut state, Message::StockpileCardRightPressed(7), &db); assert_eq!(state.stockpile_context_menu.as_ref().map(|menu| menu.id), Some(7));
let _ = update(&mut state, Message::StockpileContextMenuClosed, &db); assert!(state.stockpile_context_menu.is_none()); }
#[tokio::test] async fn it_opens_changes_and_closes_the_multibuy_export() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.stockpiles = vec![card(7, "Ammo")];
let _ = update(&mut state, Message::StockpileMultibuyExportOpened(7), &db); assert_eq!(state.stockpile_multibuy_export, Some(7)); assert!(!state.stockpile_multibuy_copied);
let _ = update( &mut state, Message::StockpileMultibuyModeChanged(stockpiles::MultibuyMode::Remaining), &db, ); assert_eq!(state.stockpile_multibuy_mode, stockpiles::MultibuyMode::Remaining);
let _ = update(&mut state, Message::StockpileMultibuyExportCopied(7), &db); assert!(state.stockpile_multibuy_copied);
let _ = update(&mut state, Message::StockpileMultibuyExportClosed, &db); assert!(state.stockpile_multibuy_export.is_none()); }
#[tokio::test] async fn it_toggles_a_card_expanded_then_collapsed() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::StockpileItemsToggled(7), &db); assert!(state.stockpile_expanded.contains(&7));
let _ = update(&mut state, Message::StockpileItemsToggled(7), &db); assert!(!state.stockpile_expanded.contains(&7)); } }
mod update { use pretty_assertions::assert_eq;
use super::*;
fn loaded_with_items(items: i64) -> Box<Loaded> { Box::new(Loaded { corporations: vec![], geo_tree: GeoTree::default(), inventory: vec![], roster: vec![], saved_filters: vec![], totals: InventoryTotals { items, locations: 1, value: 0.0, volume: 0.0, }, values: values::ValueSummary::default(), nav: tracker::NavSeries::default(), stockpiles: vec![], abyssals: abyssals::AbyssalsData::default(), ..Loaded::default() }) }
fn abyssal_cards(count: usize) -> Vec<abyssals::AbyssalCard> { (0..count) .map(|i| abyssals::AbyssalCard { character_id: 1, estimate: None, group_type_id: 2410, item_id: i as i64, location: String::new(), module_name: "Module".to_owned(), owner_name: "Vex".to_owned(), portrait: images::ImageState::Stale { id: 1, kind: images::ImageKind::CharacterPortrait, }, price_unavailable: false, stats: Vec::new(), tier_label: "Gravid".to_owned(), type_icon: images::IconResolution::Missing, }) .collect() }
#[tokio::test] async fn a_clamped_abyssal_stat_range_is_recorded_against_its_attribute() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssal_stat_templates_for_test(vec![StatTemplate { attribute_id: 50, base_value: 40.0, bound_hi: 56.0, bound_lo: 28.0, display_name: "CPU Output".to_owned(), high_is_good: true, unit_id: Some(115), }]); state.abyssal_filters.source_type_id = Some(2410);
let _ = update( &mut state, Message::AbyssalStatRangeChanged(50, SliderEndpoint::Min, 35.0), &db, ); assert_eq!(state.abyssal_filters.stat_ranges.get(&50), Some(&(35.0, 56.0))); }
#[tokio::test] async fn a_loaded_page_is_appended_and_clears_has_more_for_a_short_page() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssals_for_test(abyssal_cards(60), Vec::new(), abyssals::Filters::default(), false); state.set_abyssal_pagination_for_test(true, true);
// A short page (fewer than PAGE_SIZE) means the set is exhausted. let epoch = state.abyssal_page_epoch.current(); let _ = update( &mut state, Message::AbyssalPageLoaded { cards: abyssal_cards(10), epoch, }, &db, );
assert_eq!(state.abyssals().len(), 70, "the page is appended to the loaded set"); assert!(!state.abyssal_has_more(), "a short page leaves no more to load"); assert!(!state.abyssal_loading()); }
#[tokio::test] async fn a_scroll_append_does_not_clobber_the_abyssal_total() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssals_for_test(abyssal_cards(60), Vec::new(), abyssals::Filters::default(), false); state.set_abyssal_pagination_for_test(true, true); let _ = update( &mut state, Message::AbyssalCardsReloaded(abyssals::FilteredCards { cards: abyssal_cards(abyssals::PAGE_SIZE as usize), total: 200, }), &db, ); state.set_abyssal_pagination_for_test(true, true);
let epoch = state.abyssal_page_epoch.current(); let _ = update( &mut state, Message::AbyssalPageLoaded { cards: abyssal_cards(10), epoch, }, &db, );
assert_eq!( state.abyssal_total(), 200, "appending a scroll page leaves the DB total untouched" ); }
#[tokio::test] async fn a_stale_abyssal_page_is_dropped_after_the_set_is_reloaded() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssals_for_test(abyssal_cards(60), Vec::new(), abyssals::Filters::default(), false); state.set_abyssal_pagination_for_test(true, true);
// The user scrolled (capturing the epoch) then changed the filter, which reloads the set and // bumps the epoch. let stale_epoch = state.abyssal_page_epoch.current(); let _ = update( &mut state, Message::AbyssalCardsReloaded(abyssals::FilteredCards { cards: abyssal_cards(10), total: 10, }), &db, );
let _ = update( &mut state, Message::AbyssalPageLoaded { cards: abyssal_cards(5), epoch: stale_epoch, }, &db, );
assert_eq!( state.abyssals().len(), 10, "a page from the superseded filter must not append foreign cards" ); }
#[tokio::test] async fn an_abyssal_stat_range_change_without_loaded_templates_is_a_noop() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.abyssal_filters.source_type_id = Some(2410);
let _ = update( &mut state, Message::AbyssalStatRangeChanged(50, SliderEndpoint::Min, 5.0), &db, );
assert!(state.abyssal_filters.stat_ranges.is_empty()); }
#[tokio::test] async fn committing_a_slider_value_edit_applies_the_typed_bound() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssal_stat_templates_for_test(vec![StatTemplate { attribute_id: 50, base_value: 40.0, bound_hi: 56.0, bound_lo: 28.0, display_name: "CPU Output".to_owned(), high_is_good: true, unit_id: Some(115), }]); state.abyssal_filters.source_type_id = Some(2410);
let _ = update( &mut state, Message::AbyssalSliderEditStarted(50, SliderEndpoint::Min, 28.0), &db, ); assert_eq!(state.abyssal_slider_edit, Some((50, SliderEndpoint::Min)));
let _ = update(&mut state, Message::AbyssalSliderEditInput("33".to_owned()), &db); let _ = update( &mut state, Message::AbyssalSliderEditCommitted(50, SliderEndpoint::Min), &db, );
assert_eq!(state.abyssal_slider_edit, None); assert_eq!(state.abyssal_filters.stat_ranges.get(&50), Some(&(33.0, 56.0))); }
#[tokio::test] async fn it_applies_search_results_for_the_current_generation() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.search_generation = 7; state.totals.items = 99;
let _ = update( &mut state, Message::SearchReloaded { generation: 7, loaded: loaded_with_items(5), }, &db, );
assert_eq!(state.totals.items, 5); }
#[tokio::test] async fn it_bumps_the_search_generation_on_each_keystroke() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::SearchChanged("r".to_owned()), &db); let _ = update(&mut state, Message::SearchChanged("ri".to_owned()), &db);
assert_eq!(state.search_generation, 2); }
#[tokio::test] async fn it_covers_the_remaining_inventory_message_branches() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::AssetChartHovered(Some(0.5)), &db); assert_eq!(state.chart_hover, Some(0.5));
let _ = update(&mut state, Message::InventoryHelpToggled, &db); assert!(state.inventory_help_open);
let _ = update(&mut state, Message::CategorySelected(Category::Ship), &db); let _ = update(&mut state, Message::CategorySelected(Category::Ship), &db); assert_eq!(state.category, Category::Ship);
let _ = update(&mut state, Message::SortSelected(SortColumn::Name), &db); let _ = update(&mut state, Message::SortSelected(SortColumn::Name), &db); assert_eq!(state.sort_dir, SortDirection::Ascending);
let active = state.active; let _ = update(&mut state, Message::ScopeSelected(active), &db); assert_eq!(state.active, active);
let _ = update(&mut state, Message::SearchSubmitted, &db); let _ = update(&mut state, Message::FilterExamplePicked("ship"), &db); assert!(!state.inventory_help_open); }
#[tokio::test] async fn it_drops_search_results_from_a_superseded_generation() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.search_generation = 8; state.totals.items = 99;
let _ = update( &mut state, Message::SearchReloaded { generation: 7, loaded: loaded_with_items(5), }, &db, );
assert_eq!(state.totals.items, 99, "a stale keystroke's result is ignored"); }
#[tokio::test] async fn it_invalidates_in_flight_searches_when_a_discrete_reload_runs() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::SearchChanged("rifter".to_owned()), &db); let stale_generation = state.search_generation; let _ = update(&mut state, Message::CategorySelected(Category::Ship), &db);
assert_ne!( state.search_generation, stale_generation, "changing category supersedes the pending search" ); }
#[tokio::test] async fn it_records_the_loaded_roster_and_totals() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update( &mut state, Message::Loaded(Box::new(Loaded { corporations: vec![corp(98)], geo_tree: GeoTree::default(), inventory: vec![], roster: vec![pilot(7)], saved_filters: vec![], totals: InventoryTotals { items: 5, locations: 2, value: 100.0, volume: 50.0, }, values: values::ValueSummary::default(), nav: tracker::NavSeries::default(), stockpiles: vec![], abyssals: abyssals::AbyssalsData::default(), ..Loaded::default() })), &db, );
assert_eq!(state.roster, vec![pilot(7)]); assert_eq!(state.corporations, vec![corp(98)]); assert_eq!(state.totals.items, 5); }
#[tokio::test] async fn it_records_the_search_string() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::SearchChanged("tritanium".to_owned()), &db); assert_eq!(state.search, "tritanium"); }
#[tokio::test] async fn it_records_the_selected_inventory_category() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::CategorySelected(Category::Ship), &db); assert_eq!(state.category, Category::Ship); }
#[tokio::test] async fn it_records_the_selected_scope_and_closes_the_picker() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.picker_open = true;
let _ = update(&mut state, Message::ScopeSelected(Scope::Character(42)), &db);
assert_eq!(state.active, Scope::Character(42)); assert!(!state.picker_open); }
#[tokio::test] async fn it_switches_the_active_tab() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::TabSelected(Tab::Values), &db); assert_eq!(state.tab, Tab::Values); }
#[tokio::test] async fn it_toggles_the_abyssal_picker() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::AbyssalPickerToggled, &db); assert!(state.abyssal_picker_open); let _ = update(&mut state, Message::AbyssalPickerToggled, &db); assert!(!state.abyssal_picker_open); }
#[tokio::test] async fn it_toggles_the_picker_open_and_closed() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default());
let _ = update(&mut state, Message::PickerToggled, &db); assert!(state.picker_open);
let _ = update(&mut state, Message::PickerToggled, &db); assert!(!state.picker_open); }
#[tokio::test] async fn reloading_cards_replaces_the_set_and_resets_pagination() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssals_for_test(abyssal_cards(60), Vec::new(), abyssals::Filters::default(), false); state.set_abyssal_pagination_for_test(true, true); let _ = update( &mut state, Message::AbyssalGridScrolled { absolute: 999.0, relative: 0.9, }, &db, );
// A reload that returns a full page keeps has_more true and clears loading/offset. let _ = update( &mut state, Message::AbyssalCardsReloaded(abyssals::FilteredCards { cards: abyssal_cards(abyssals::PAGE_SIZE as usize), total: 137, }), &db, );
assert_eq!(state.abyssals().len(), abyssals::PAGE_SIZE as usize); assert_eq!(state.abyssal_total(), 137, "the reload feeds the filter-aware DB total"); assert!(state.abyssal_has_more(), "a full reload page implies more to load"); assert!(!state.abyssal_loading()); assert_eq!( state.abyssal_scroll_offset(), 0.0, "a reload returns the grid to the top" ); }
#[tokio::test] async fn resetting_the_abyssal_filters_clears_type_and_ranges() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.abyssal_filters.source_type_id = Some(2410); state.abyssal_filters.stat_ranges.insert(50, (1.0, 2.0));
let _ = update(&mut state, Message::AbyssalFilterReset, &db);
assert_eq!(state.abyssal_filters, abyssals::Filters::default()); }
#[tokio::test] async fn scrolling_below_the_threshold_only_tracks_the_offset() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssals_for_test(abyssal_cards(60), Vec::new(), abyssals::Filters::default(), false); state.set_abyssal_pagination_for_test(true, false);
let _ = update( &mut state, Message::AbyssalGridScrolled { absolute: 120.0, relative: 0.5, }, &db, );
assert!(!state.abyssal_loading(), "a shallow scroll does not page"); assert_eq!(state.abyssal_scroll_offset(), 120.0); }
#[tokio::test] async fn scrolling_does_not_load_when_already_loading_or_exhausted() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssals_for_test(abyssal_cards(60), Vec::new(), abyssals::Filters::default(), false);
// No more pages: a threshold scroll must not start a load. state.set_abyssal_pagination_for_test(false, false); let _ = update( &mut state, Message::AbyssalGridScrolled { absolute: 0.0, relative: 0.95, }, &db, ); assert!(!state.abyssal_loading(), "no load is started once the set is exhausted");
// Already loading: a second threshold scroll must not start a duplicate load. state.set_abyssal_pagination_for_test(true, true); let _ = update( &mut state, Message::AbyssalGridScrolled { absolute: 0.0, relative: 0.95, }, &db, ); assert!(state.abyssal_loading()); }
#[tokio::test] async fn scrolling_past_the_threshold_starts_loading_the_next_page() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.set_abyssals_for_test(abyssal_cards(60), Vec::new(), abyssals::Filters::default(), false); state.set_abyssal_pagination_for_test(true, false);
let _ = update( &mut state, Message::AbyssalGridScrolled { absolute: 2_000.0, relative: 0.9, }, &db, );
assert!(state.abyssal_loading(), "crossing the threshold begins a page load"); assert_eq!( state.abyssal_scroll_offset(), 2_000.0, "the pixel offset is tracked for windowing" ); }
#[tokio::test] async fn selecting_an_abyssal_source_type_records_it_and_closes_the_picker() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(crate::config::FeatureFlags::default()); state.abyssal_picker_open = true; state.abyssal_filters.stat_ranges.insert(50, (1.0, 2.0));
let _ = update(&mut state, Message::AbyssalSourceTypeSelected(Some(2410)), &db);
assert_eq!(state.abyssal_filters.source_type_id, Some(2410)); assert!(state.abyssal_filters.stat_ranges.is_empty()); assert!(!state.abyssal_picker_open); } }
mod view { use super::*;
#[test] fn it_renders_a_loaded_state() { let mut state = State::new(crate::config::FeatureFlags::default()); state.roster = vec![pilot(1)]; state.active = Scope::Character(1); state.totals = InventoryTotals { items: 5, locations: 2, value: 1_000.0, volume: 200.0, };
let _el: Element<'_, Message> = view(&state, Utc::now()); }
#[test] fn it_renders_the_abyssal_picker_modal_overlay() { let mut state = State::new(crate::config::FeatureFlags::default()); state.roster = vec![pilot(1)]; state.tab = Tab::Abyssals; state.abyssal_picker_open = true;
let _el: Element<'_, Message> = view(&state, Utc::now()); }
#[test] fn it_renders_the_abyssals_tab_with_its_resizable_filter_rail() { let mut state = State::new(crate::config::FeatureFlags::default()); state.roster = vec![pilot(1)]; state.tab = Tab::Abyssals;
let _el: Element<'_, Message> = view(&state, Utc::now()); }
#[test] fn it_renders_the_empty_state_before_any_load() { let state = State::new(crate::config::FeatureFlags::default());
let _el: Element<'_, Message> = view(&state, Utc::now()); }
#[test] fn it_renders_the_inventory_help_overlay() { let mut state = State::new(crate::config::FeatureFlags::default()); state.roster = vec![pilot(1)]; state.tab = Tab::Inventory; state.inventory_help_open = true;
let _el: Element<'_, Message> = view(&state, Utc::now()); }
#[test] fn it_renders_the_scope_picker_overlay() { let mut state = State::new(crate::config::FeatureFlags::default()); state.roster = vec![pilot(1)]; state.picker_open = true;
let _el: Element<'_, Message> = view(&state, Utc::now()); }
#[test] fn it_renders_the_stockpile_context_menu_overlay() { let mut state = State::new(crate::config::FeatureFlags::default()); state.roster = vec![pilot(1)]; state.tab = Tab::Stockpiles; state.stockpile_context_menu = Some(StockpileContextMenu { anchor: iced::Point::new(10.0, 10.0), id: 1, name: "Ammo".to_owned(), });
let _el: Element<'_, Message> = view(&state, Utc::now()); } }
mod asset_tags { use pretty_assertions::assert_eq;
use super::*;
fn flags() -> crate::config::FeatureFlags { crate::config::FeatureFlags::default() }
fn test_row(item_id: i64, type_name: &str) -> InventoryRow { InventoryRow { category: "ship".to_owned(), container_id: None, depth: 0, group_name: "Frigate".to_owned(), is_active_ship: false, is_blueprint_copy: None, is_container: false, item_id, location_flag: "Hangar".to_owned(), location_id: 60_003_760, location_label: Some("Jita IV - Moon 4".to_owned()), name: None, owner_id: 7, quantity: 1, reproc_value: 0.0, row_volume: 10.0, type_icon: images::IconResolution::Missing, type_id: 587, type_name: type_name.to_owned(), unit_price: 100.0, value: 100.0, } }
async fn seeded_tag(db: &Database, name: &str) -> Tag { infra::create_scoped(db, name, None, None, TAG_SCOPE_ASSET) .await .unwrap() }
#[tokio::test] async fn opening_the_modal_keys_it_on_the_item_id_and_resolves_the_row_name() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); state.inventory = vec![InventoryRow { name: Some("Loot Run".to_owned()), ..test_row(5001, "Giant Secure Container") }];
let _ = update(&mut state, Message::InventoryRowClicked(5001), &db); let _ = update(&mut state, Message::OpenSelectionTagModal, &db);
let modal = state.asset_tag_modal().expect("the modal is open"); assert_eq!(modal.entity_id, 5001); assert_eq!(modal.entity_type, ENTITY_TYPE_ASSET); assert_eq!(state.asset_tag_modal_entity_name(), "Loot Run"); }
#[tokio::test] async fn the_modal_input_round_trips_and_close_dismisses_it() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); open_modal(&mut state, &db, 5001);
let _ = update( &mut state, Message::AssetTagModal(AddTagMessage::InputChanged("Keep".to_owned())), &db, ); assert_eq!(state.asset_tag_modal().map(|m| m.input.as_str()), Some("Keep"));
let _ = update(&mut state, Message::AssetTagModal(AddTagMessage::Close), &db); assert!(state.asset_tag_modal().is_none()); }
#[tokio::test] async fn assign_then_unassign_round_trips_the_membership_for_an_item_id() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); let keep = seeded_tag(&db, "Keep").await;
// Assign: the same write path the modal's Assign arm performs, then reload into state. infra::assign(&db, ENTITY_TYPE_ASSET, 5001, keep.id()).await.unwrap(); let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
assert_eq!( state.asset_tags_for(5001).iter().map(|t| t.id()).collect::<Vec<_>>(), vec![keep.id()] );
// Unassign: the modal's Unassign arm write, reloaded — the chip disappears. infra::unassign(&db, ENTITY_TYPE_ASSET, 5001, keep.id()).await.unwrap(); let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
assert!(state.asset_tags_for(5001).is_empty()); }
#[tokio::test] async fn the_modal_partition_splits_assigned_from_assignable_for_the_target_item() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); let keep = seeded_tag(&db, "Keep").await; let _sell = seeded_tag(&db, "Sell").await; infra::assign(&db, ENTITY_TYPE_ASSET, 5001, keep.id()).await.unwrap();
open_modal(&mut state, &db, 5001); let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
let (assigned, assignable) = state.asset_tag_modal_partition(); assert_eq!(assigned.iter().map(|t| t.name().as_str()).collect::<Vec<_>>(), ["Keep"]); assert_eq!( assignable.iter().map(|t| t.name().as_str()).collect::<Vec<_>>(), ["Sell"] ); }
#[tokio::test] async fn the_loaded_inventory_carries_the_asset_tag_registry_and_membership_map() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); let keep = seeded_tag(&db, "Keep").await; infra::assign(&db, ENTITY_TYPE_ASSET, 5001, keep.id()).await.unwrap();
let loaded = load_assets(db.clone(), Scope::All, InventoryView::default()).await; apply_loaded(&mut state, loaded);
assert_eq!( state.asset_tags.iter().map(|t| t.name().as_str()).collect::<Vec<_>>(), ["Keep"] ); assert_eq!(state.tag_memberships.get(&5001), Some(&vec![keep.id()])); }
// Selects a single row and opens the shared add-tag modal over that selection — the right-click // Edit Tags path. The returned `Task`s are not run, so the assertions target the synchronous state // transitions and the task shape (units) each arm produces. fn open_modal(state: &mut State, db: &Database, item_id: i64) { if !state.inventory.iter().any(|row| row.item_id == item_id) { state.inventory.push(test_row(item_id, "Item")); } let _ = update(state, Message::InventoryRowClicked(item_id), db); let _ = update(state, Message::OpenSelectionTagModal, db); }
#[tokio::test] async fn input_changed_with_no_modal_open_is_a_no_op() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags());
// No modal is open, so the `Some(modal)` guard is skipped and nothing is stored. let task = update( &mut state, Message::AssetTagModal(AddTagMessage::InputChanged("orphaned".to_owned())), &db, );
assert!(state.asset_tag_modal().is_none()); assert_eq!(task.units(), 0, "the no-modal input change produces no task"); }
#[tokio::test] async fn assign_through_the_modal_fans_a_write_for_the_target() { let db = crate::store::open_test().await.unwrap(); let keep = seeded_tag(&db, "Keep").await; let mut state = State::new(flags()); open_modal(&mut state, &db, 5001);
// The Assign arm fans `fan_tag_write` over the modal's single target, producing a write task. let task = update( &mut state, Message::AssetTagModal(AddTagMessage::Assign { entity_id: 5001, entity_type: ENTITY_TYPE_ASSET, tag_id: keep.id(), }), &db, );
assert!(task.units() > 0, "the Assign arm must produce a write task"); }
#[tokio::test] async fn unassign_through_the_modal_fans_a_write_for_the_target() { let db = crate::store::open_test().await.unwrap(); let keep = seeded_tag(&db, "Keep").await; let mut state = State::new(flags()); open_modal(&mut state, &db, 5001);
let task = update( &mut state, Message::AssetTagModal(AddTagMessage::Unassign { entity_id: 5001, entity_type: ENTITY_TYPE_ASSET, tag_id: keep.id(), }), &db, );
assert!(task.units() > 0, "the Unassign arm must produce a write task"); }
#[tokio::test] async fn create_and_assign_with_a_blank_name_short_circuits() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); open_modal(&mut state, &db, 5001); state.selection_tag_ids = vec![5001]; // Whitespace-only input trims to empty, so the `filter(!is_empty)` arm bails before any write. if let Some(modal) = &mut state.add_tag_modal { modal.input = " ".to_owned(); }
let task = update( &mut state, Message::AssetTagModal(AddTagMessage::CreateAndAssign { entity_id: 5001, entity_type: ENTITY_TYPE_ASSET, }), &db, );
assert_eq!(task.units(), 0, "a blank name creates no tag and no task"); }
#[tokio::test] async fn create_and_assign_with_an_empty_selection_short_circuits() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); open_modal(&mut state, &db, 5001); // A real name, but no stacks selected: the empty-selection guard returns before any write. if let Some(modal) = &mut state.add_tag_modal { modal.input = "Keep".to_owned(); } state.selection_tag_ids = Vec::new();
let task = update( &mut state, Message::AssetTagModal(AddTagMessage::CreateAndAssign { entity_id: 5001, entity_type: ENTITY_TYPE_ASSET, }), &db, );
assert_eq!(task.units(), 0, "an empty selection creates no tag and no task"); assert_eq!( state.asset_tag_modal().map(|m| m.input.as_str()), Some("Keep"), "the input is left untouched when the selection guard bails" ); }
#[tokio::test] async fn create_and_assign_a_new_name_clears_the_input_and_fans_a_write() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); open_modal(&mut state, &db, 5001); state.selection_tag_ids = vec![5001, 5002]; // A fresh name with no existing match takes the `None => create_scoped` branch and clears input. if let Some(modal) = &mut state.add_tag_modal { modal.input = "Brand New".to_owned(); }
let task = update( &mut state, Message::AssetTagModal(AddTagMessage::CreateAndAssign { entity_id: 5001, entity_type: ENTITY_TYPE_ASSET, }), &db, );
assert!(task.units() > 0, "creating a new tag fans a write task"); assert_eq!( state.asset_tag_modal().map(|m| m.input.as_str()), Some(""), "the input is cleared once the create is dispatched" );
// Run the dispatched create+assign (mirrors the task body), then reload to observe the effect. let created = infra::create_scoped(&db, "Brand New", None, None, TAG_SCOPE_ASSET) .await .unwrap(); for id in [5001, 5002] { infra::assign(&db, ENTITY_TYPE_ASSET, id, created.id()).await.unwrap(); } let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db); for id in [5001, 5002] { assert_eq!( state .asset_tags_for(id) .iter() .map(|t| t.name().as_str()) .collect::<Vec<_>>(), ["Brand New"], "stack {id} carries the newly created tag" ); } }
#[tokio::test] async fn create_and_assign_reuses_an_existing_tag_case_insensitively() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); let keep = seeded_tag(&db, "Keep").await; // Load the existing "Keep" tag into the registry so the reuse lookup can find it. let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db); open_modal(&mut state, &db, 5001); state.selection_tag_ids = vec![5001]; // Different casing must still match the existing tag via `normalize_tag_name`, taking the // `Some(id)` reuse branch rather than creating a duplicate. if let Some(modal) = &mut state.add_tag_modal { modal.input = "kEeP".to_owned(); }
let task = update( &mut state, Message::AssetTagModal(AddTagMessage::CreateAndAssign { entity_id: 5001, entity_type: ENTITY_TYPE_ASSET, }), &db, );
assert!(task.units() > 0, "reusing an existing tag still fans a write task"); assert_eq!( state.asset_tag_modal().map(|m| m.input.as_str()), Some(""), "the input is cleared on the reuse path too" );
// Apply the reuse (assign the existing tag), reload, and confirm no duplicate tag was created. infra::assign(&db, ENTITY_TYPE_ASSET, 5001, keep.id()).await.unwrap(); let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db); assert_eq!( state .asset_tags .iter() .filter(|t| t.name().eq_ignore_ascii_case("keep")) .count(), 1, "the case-insensitive match reuses the single existing tag" ); assert_eq!( state.asset_tags_for(5001).iter().map(|t| t.id()).collect::<Vec<_>>(), vec![keep.id()] ); }
#[tokio::test] async fn create_and_assign_in_memory_guard_agrees_with_the_repo_on_unicode_case() { // The in-memory fast path must stay in lock-step with the store. The store keys tags on SQLite's // ASCII-only `lower()`, so "Étagère" and "étagère" are DISTINCT rows there. The guard (built on // `normalize_tag_name`, which ASCII-folds to match) agrees: it does NOT treat the existing // "Étagère" as a reuse target for "étagère", and the repo find-or-create likewise inserts a // second row rather than reusing — no false dedup, no divergence between guard and store. let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags()); let existing = seeded_tag(&db, "Étagère").await; let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
let resolved = infra::create_scoped(&db, "étagère", None, None, TAG_SCOPE_ASSET) .await .unwrap(); assert_ne!( resolved.id(), existing.id(), "the store keys on ASCII lower(), so the Unicode-case variant is a distinct row" );
// The in-memory guard reaches the same verdict: no ASCII-equal match against the existing row. let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db); let needle = normalize_tag_name("étagère"); assert!( !state .asset_tags .iter() .any(|t| normalize_tag_name(t.name()) == needle && t.id() == existing.id()), "the guard does not falsely match the Unicode-case variant against the existing row" ); }
// The three named futures the modal arms dispatch. Awaiting them directly exercises the // write-then-reload bodies (the work the returned `Task`s perform when iced polls them).
#[tokio::test] async fn assign_then_reload_writes_the_membership_and_returns_a_reload() { let db = crate::store::open_test().await.unwrap(); let keep = seeded_tag(&db, "Keep").await; let mut state = State::new(flags());
let message = assign_then_reload(db.clone(), ENTITY_TYPE_ASSET, 5001, keep.id()).await; let _ = update(&mut state, message, &db);
assert_eq!( state.asset_tags_for(5001).iter().map(|t| t.id()).collect::<Vec<_>>(), vec![keep.id()], "the assign future persists the membership before reloading" ); }
#[tokio::test] async fn unassign_then_reload_removes_the_membership_and_returns_a_reload() { let db = crate::store::open_test().await.unwrap(); let keep = seeded_tag(&db, "Keep").await; infra::assign(&db, ENTITY_TYPE_ASSET, 5001, keep.id()).await.unwrap(); let mut state = State::new(flags());
let message = unassign_then_reload(db.clone(), ENTITY_TYPE_ASSET, 5001, keep.id()).await; let _ = update(&mut state, message, &db);
assert!( state.asset_tags_for(5001).is_empty(), "the unassign future removes the membership before reloading" ); }
#[tokio::test] async fn create_or_reuse_creates_a_new_tag_and_assigns_every_stack() { let db = crate::store::open_test().await.unwrap(); let mut state = State::new(flags());
// existing is None, so the future takes the `create_scoped` branch, then bulk-assigns. let message = create_or_reuse_then_assign( db.clone(), "Brand New".to_owned(), None, ENTITY_TYPE_ASSET, vec![5001, 5002], ) .await; let _ = update(&mut state, message, &db);
assert_eq!( state.asset_tags.iter().filter(|t| t.name() == "Brand New").count(), 1, "exactly one tag is created" ); for id in [5001, 5002] { assert_eq!( state .asset_tags_for(id) .iter() .map(|t| t.name().as_str()) .collect::<Vec<_>>(), ["Brand New"], "stack {id} carries the created tag" ); } }
#[tokio::test] async fn create_or_reuse_reuses_an_existing_tag_id_without_creating_a_duplicate() { let db = crate::store::open_test().await.unwrap(); let keep = seeded_tag(&db, "Keep").await; let mut state = State::new(flags());
// existing is Some(id), so the future skips creation and assigns the supplied tag id. let message = create_or_reuse_then_assign( db.clone(), "Keep".to_owned(), Some(keep.id()), ENTITY_TYPE_ASSET, vec![5001], ) .await; let _ = update(&mut state, message, &db);
assert_eq!( state.asset_tags.iter().filter(|t| t.name() == "Keep").count(), 1, "no duplicate tag is created on the reuse path" ); assert_eq!( state.asset_tags_for(5001).iter().map(|t| t.id()).collect::<Vec<_>>(), vec![keep.id()] ); }
#[tokio::test] async fn create_or_reuse_with_no_targets_still_reloads_cleanly() { let db = crate::store::open_test().await.unwrap(); let keep = seeded_tag(&db, "Keep").await; let mut state = State::new(flags());
// A resolvable tag but no item ids: the assign loop is skipped and the future just reloads. let message = create_or_reuse_then_assign( db.clone(), "Keep".to_owned(), Some(keep.id()), ENTITY_TYPE_ASSET, Vec::new(), ) .await; let _ = update(&mut state, message, &db);
assert!( state.tag_memberships.is_empty() || state.tag_memberships.values().all(|ids| ids.is_empty()), "no memberships are written when there are no target stacks" ); } }
mod inventory_selection { use pretty_assertions::assert_eq;
use super::*;
fn row(item_id: i64) -> InventoryRow { InventoryRow { category: "ship".to_owned(), container_id: None, depth: 0, group_name: "Frigate".to_owned(), is_active_ship: false, is_blueprint_copy: None, is_container: false, item_id, location_flag: "Hangar".to_owned(), location_id: 60_003_760, location_label: Some("Jita".to_owned()), name: None, owner_id: 7, quantity: 1, reproc_value: 0.0, row_volume: 10.0, type_icon: images::IconResolution::Missing, type_id: 587, type_name: "Rifter".to_owned(), unit_price: 100.0, value: 100.0, } }
fn seeded(rows: &[i64]) -> State { let mut state = State::new(crate::config::FeatureFlags::default()); state.inventory = rows.iter().map(|&id| row(id)).collect(); state }
async fn db() -> Database { crate::store::open_test().await.unwrap() }
async fn seeded_tag(db: &Database, name: &str) -> Tag { infra::create_scoped(db, name, None, None, TAG_SCOPE_ASSET) .await .unwrap() }
#[tokio::test] async fn a_plain_click_selects_a_single_row_and_a_modifier_click_extends() { let db = db().await; let mut state = seeded(&[1, 2, 3, 4]);
let _ = update(&mut state, Message::InventoryRowClicked(2), &db); assert!(state.inventory_row_selected(2)); assert_eq!(state.inventory_selection_count(), 1);
// Shift extends a contiguous range from the anchor. let _ = update( &mut state, Message::InventoryModifiersChanged(iced::keyboard::Modifiers::SHIFT), &db, ); let _ = update(&mut state, Message::InventoryRowClicked(4), &db); assert_eq!( state.inventory_selection.ordered(&state.inventory_order()), vec![2, 3, 4] );
// ⌘ toggles an individual row out. let _ = update( &mut state, Message::InventoryModifiersChanged(iced::keyboard::Modifiers::COMMAND), &db, ); let _ = update(&mut state, Message::InventoryRowClicked(3), &db); assert_eq!(state.inventory_selection.ordered(&state.inventory_order()), vec![2, 4]); }
#[tokio::test] async fn a_right_click_outside_the_selection_reselects_then_opens_the_menu() { let db = db().await; let mut state = seeded(&[1, 2, 3]); let _ = update(&mut state, Message::InventoryRowClicked(1), &db); let _ = update( &mut state, Message::InventoryCursorMoved(iced::Point::new(20.0, 40.0)), &db, );
let _ = update(&mut state, Message::InventoryRowRightPressed(3), &db);
// Row 3 was not selected, so it becomes the lone selection, and the menu opens at the cursor. assert_eq!(state.inventory_selection.ordered(&state.inventory_order()), vec![3]); assert_eq!(state.inventory_menu(), Some(iced::Point::new(20.0, 40.0)));
let _ = update(&mut state, Message::InventoryMenuDismissed, &db); assert!(state.inventory_menu().is_none()); }
#[tokio::test] async fn a_right_click_inside_the_selection_keeps_it() { let db = db().await; let mut state = seeded(&[1, 2, 3]); let _ = update(&mut state, Message::InventoryRowClicked(1), &db); let _ = update( &mut state, Message::InventoryModifiersChanged(iced::keyboard::Modifiers::SHIFT), &db, ); let _ = update(&mut state, Message::InventoryRowClicked(3), &db); let _ = update( &mut state, Message::InventoryCursorMoved(iced::Point::new(5.0, 5.0)), &db, );
let _ = update(&mut state, Message::InventoryRowRightPressed(2), &db);
// The whole range stays selected — the menu acts on it, not just the pointed row. assert_eq!( state.inventory_selection.ordered(&state.inventory_order()), vec![1, 2, 3] ); }
#[tokio::test] async fn a_reload_prunes_rows_that_left_the_filter() { let db = db().await; let mut state = seeded(&[1, 2, 3]); let _ = update(&mut state, Message::InventoryRowClicked(2), &db); let _ = update( &mut state, Message::InventoryModifiersChanged(iced::keyboard::Modifiers::COMMAND), &db, ); let _ = update(&mut state, Message::InventoryRowClicked(3), &db); assert_eq!(state.inventory_selection_count(), 2);
// A reload narrows the inventory to row 2 only; row 3 drops out of the selection. let loaded = Loaded { inventory: vec![row(2)], ..Loaded::default() }; apply_loaded(&mut state, loaded);
assert_eq!(state.inventory_selection.ordered(&state.inventory_order()), vec![2]); assert!(!state.inventory_row_selected(3)); }
/// Opening Edit Tags over a selection captures every selected stack into `selection_tag_ids`, the /// set the modal's Assign/Unassign arms fan their write across. The async writes themselves are /// driven separately (the returned `Task` is not executed in a unit test) to assert the bulk effect. #[tokio::test] async fn opening_edit_tags_targets_every_selected_stack() { let db = db().await; let mut state = seeded(&[10, 20, 30]);
let _ = update(&mut state, Message::InventoryRowClicked(10), &db); let _ = update( &mut state, Message::InventoryModifiersChanged(iced::keyboard::Modifiers::SHIFT), &db, ); let _ = update(&mut state, Message::InventoryRowClicked(30), &db); let _ = update(&mut state, Message::OpenSelectionTagModal, &db);
assert!(state.asset_tag_modal().is_some()); assert!(state.inventory_menu().is_none()); assert_eq!(state.selection_tag_ids, vec![10, 20, 30]); }
#[tokio::test] async fn a_bulk_assign_applies_the_tag_to_every_targeted_stack() { let db = db().await; let keep = seeded_tag(&db, "Keep").await; let mut state = seeded(&[10, 20, 30]); state.selection_tag_ids = vec![10, 20, 30];
// Mirror the write the modal's Assign arm fans across `selection_tag_ids`. for &id in &state.selection_tag_ids { infra::assign(&db, ENTITY_TYPE_ASSET, id, keep.id()).await.unwrap(); } let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
for id in [10, 20, 30] { assert_eq!( state.asset_tags_for(id).iter().map(|t| t.id()).collect::<Vec<_>>(), vec![keep.id()], "stack {id} should carry the bulk-assigned tag" ); } }
#[tokio::test] async fn a_bulk_unassign_removes_the_tag_from_every_targeted_stack() { let db = db().await; let keep = seeded_tag(&db, "Keep").await; for id in [10, 20, 30] { infra::assign(&db, ENTITY_TYPE_ASSET, id, keep.id()).await.unwrap(); } let mut state = seeded(&[10, 20, 30]); state.selection_tag_ids = vec![10, 20, 30];
// Mirror the write the modal's Unassign arm fans across `selection_tag_ids`. for &id in &state.selection_tag_ids { infra::unassign(&db, ENTITY_TYPE_ASSET, id, keep.id()).await.unwrap(); } let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
for id in [10, 20, 30] { assert!( state.asset_tags_for(id).is_empty(), "stack {id} should have the bulk-unassigned tag removed" ); } }
#[test] fn a_chip_unassign_with_no_modal_open_targets_the_messages_own_stack() { let state = seeded(&[10, 20, 30]);
// No modal open, so `selection_tag_ids` is empty: the unassign must target the chip's own stack. assert_eq!(tag_write_targets(&state, 20), vec![20]); }
#[test] fn a_tag_write_with_a_populated_selection_fans_across_the_whole_selection() { let mut state = seeded(&[10, 20, 30]); state.selection_tag_ids = vec![10, 20, 30];
// With a bulk modal open the selection wins over the message's single stack. assert_eq!(tag_write_targets(&state, 10), vec![10, 20, 30]); }
#[tokio::test] async fn a_chip_unassign_through_update_removes_the_tag_from_the_clicked_stack() { let db = db().await; let keep = seeded_tag(&db, "Keep").await; for id in [10, 20] { infra::assign(&db, ENTITY_TYPE_ASSET, id, keep.id()).await.unwrap(); } let mut state = seeded(&[10, 20]); let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
// Driving the chip's `×` message through `update` with no modal open must not short-circuit: // the prior bug returned `Task::none()` when `selection_tag_ids` was empty, so nothing happened. let task = update( &mut state, Message::AssetTagModal(AddTagMessage::Unassign { entity_id: 20, entity_type: ENTITY_TYPE_ASSET, tag_id: keep.id(), }), &db, ); assert!( task.units() > 0, "the chip unassign must produce a write task, not a no-op" );
// Apply the unassign the task fans (mirrors the task's body) and reload to observe the effect. infra::unassign(&db, ENTITY_TYPE_ASSET, 20, keep.id()).await.unwrap(); let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db);
assert!( state.asset_tags_for(20).is_empty(), "the clicked stack should have its tag removed" ); assert_eq!( state.asset_tags_for(10).iter().map(|t| t.id()).collect::<Vec<_>>(), vec![keep.id()], "an untouched stack keeps its tag" ); }
#[tokio::test] async fn the_bulk_partition_uses_the_intersection_of_the_selection() { let db = db().await; let keep = seeded_tag(&db, "Keep").await; let sell = seeded_tag(&db, "Sell").await; // Keep is on both stacks; Sell only on one — so only Keep is "assigned" across the selection. infra::assign(&db, ENTITY_TYPE_ASSET, 10, keep.id()).await.unwrap(); infra::assign(&db, ENTITY_TYPE_ASSET, 20, keep.id()).await.unwrap(); infra::assign(&db, ENTITY_TYPE_ASSET, 10, sell.id()).await.unwrap();
let mut state = seeded(&[10, 20]); let _ = update(&mut state, reload_asset_tags(db.clone()).await, &db); let _ = update(&mut state, Message::InventoryRowClicked(10), &db); let _ = update( &mut state, Message::InventoryModifiersChanged(iced::keyboard::Modifiers::SHIFT), &db, ); let _ = update(&mut state, Message::InventoryRowClicked(20), &db); let _ = update(&mut state, Message::OpenSelectionTagModal, &db);
let (assigned, assignable) = state.asset_tag_modal_partition(); assert_eq!(assigned.iter().map(|t| t.name().as_str()).collect::<Vec<_>>(), ["Keep"]); assert_eq!( assignable.iter().map(|t| t.name().as_str()).collect::<Vec<_>>(), ["Sell"] ); }
#[tokio::test] async fn the_selection_modal_titles_a_multi_row_selection_by_count() { let db = db().await; let mut state = seeded(&[10, 20]); let _ = update(&mut state, Message::InventoryRowClicked(10), &db); let _ = update( &mut state, Message::InventoryModifiersChanged(iced::keyboard::Modifiers::SHIFT), &db, ); let _ = update(&mut state, Message::InventoryRowClicked(20), &db); let _ = update(&mut state, Message::OpenSelectionTagModal, &db);
assert_eq!(state.asset_tag_modal_entity_name(), "2 stacks"); }
#[test] fn it_renders_the_inventory_context_menu_overlay() { let mut state = seeded(&[1, 2]); state.tab = Tab::Inventory; state .inventory_selection .apply(1, ClickKind::Plain, &state.inventory_order()); state.inventory_menu = Some(iced::Point::new(10.0, 10.0));
let _el: Element<'_, Message> = view(&state, Utc::now()); }
#[test] fn opening_the_inventory_menu_keeps_the_base_at_a_stable_tree_position() { use iced::advanced::widget::Tree;
// Iced matches a scrollable's internal offset by tree position + tag; if the base moved between // the closed (root container) and open (Stack child[0]) shells, the list would snap to the top // on right-click. The base must stay at child[0] with the same tag whether or not a menu is open. let mut closed = seeded(&[1, 2]); closed.tab = Tab::Inventory; let closed_view = view(&closed, Utc::now()); let closed_tree = Tree::new(closed_view.as_widget());
let mut open = seeded(&[1, 2]); open.tab = Tab::Inventory; open .inventory_selection .apply(1, ClickKind::Plain, &open.inventory_order()); open.inventory_menu = Some(iced::Point::new(10.0, 10.0)); let open_view = view(&open, Utc::now()); let open_tree = Tree::new(open_view.as_widget());
assert_eq!(closed_tree.children[0].tag, open_tree.children[0].tag); } }}