"""浏览分类、来源保护、身份衔接与分页的行为回归。""" import sqlite3 import tempfile import unittest from unittest import mock from peach import entity_classification as classification, entity_identity_research as research from peach.entities import merge_entity, upsert_asset_entity from peach.web_entity import q_index, q_entity, q_suggest from peach.web_catalog import q_items from peach.web_state import WebContract from peach.web_catalog import attach_avatar_availability from peach.performer_alias_followup import Missing, Unavailable from peach.sources.base import Page from tests.support.ledger import fresh_ledger SOUGOU = research.WIKI_ROOTS['sougouwiki'] + 'd/' def _work_table(*cast): rows = ''.join(f'COSH-00{index}こすっち00{index}' f'{name}' for index, name in enumerate(cast, 1)) return ('
' '' + rows + '
NOTITLEACTRESS
').encode() class _Pages: """站上只有 `listed` 里的页;`failing` 里的地址模拟网络失败。""" def __init__(self, listed=None, failing=()): self.listed, self.failing, self.asked = listed or {}, set(failing), [] def get(self, url): self.asked.append(url) if url in self.failing: raise Unavailable('Wiki 网络请求未取得') if url not in self.listed: raise Missing('站上没有这一页') return Page(url, self.listed[url]) class EntityClassificationTests(unittest.TestCase): def setUp(self): self.directory=tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) self.db=fresh_ledger(self.directory.name) self.connection=sqlite3.connect(self.db) self.addCleanup(self.connection.close) self.connection.row_factory=sqlite3.Row def entity(self,name,kind='creator'): self.connection.execute("INSERT INTO asset(location,path,name,medium,creator,studio) VALUES('local',?,?, 'video',?,'Publisher')", (f'B:\\西方\\Publisher\\Publisher.24.12.02.{name.replace(" ",".")}.mp4',f'{name}.mp4',name)) asset_id=self.connection.execute('SELECT last_insert_rowid()').fetchone()[0] entity_id=upsert_asset_entity(self.connection,kind=kind,name=name,asset_id=asset_id,role=kind,source='legacy:asset') self.connection.commit() return asset_id,entity_id def test_account_work_portraits_without_person_identity_are_marked_as_stand_ins(self): asset_id,entity_id=self.entity('Publisher Account') self.connection.execute("UPDATE asset SET snapshot_path='sheet.jpg' WHERE id=?",(asset_id,)) self.connection.commit() contract=WebContract(self.db) with mock.patch.object(WebContract,'has_avatar',return_value=True): row={'entity_id':entity_id,'rep':asset_id,'has_image':True} attach_avatar_availability(contract,[row]) self.assertTrue(row['has_avatar']) self.assertTrue(row['avatar_stand_in']) self.assertTrue(row['has_image']) self.claim(entity_id,'identity','person') self.connection.commit() attach_avatar_availability(contract,[row]) self.assertTrue(row['has_avatar']) self.assertNotIn('avatar_stand_in',row) def claim(self,entity_id,facet,value,**kwargs): classification.write_claim(self.connection,entity_id=entity_id,facet=facet,value=value, source='source:publisher',source_url='https://publisher.test/profile',evidence='发行方资料',status='observed',confidence=1,**kwargs) def finding(self,name,entity_id): return {'entity_id':entity_id,'name':name,'cast_studios':['Publisher'], 'cast_source_url':'https://publisher.test/profile', 'claims':[ {'facet':'identity','value':'person','source_url':'https://publisher.test/profile','evidence':'发行方署名','status':'observed','confidence':1}, {'facet':'occupation','value':'adult_performer','source_url':'https://publisher.test/profile','evidence':'发行出演名单','status':'observed','confidence':1}]} def test_multiple_markets_and_occupations_are_independent_of_entity_role(self): _,entity_id=self.entity('Known Person',kind='performer') for facet,value in [('identity','person'),('occupation','adult_performer'),('occupation','model'),('market','japanese_av'),('market','western_adult')]: self.claim(entity_id,facet,value) self.connection.commit() data=q_entity(WebContract(self.db),{'kind':'performer','name':'Known Person'}) self.assertEqual(data['identity_labels'],['女优','西方']) self.assertEqual(len(classification.classifications(self.connection,[entity_id])[entity_id]),5) self.assertNotIn('classifications',data) def test_candidates_and_search_hits_do_not_qualify_for_trusted_filters(self): _,known=self.entity('Known Person'); _,unknown=self.entity('Unknown Person') self.claim(known,'account_role','seller') classification.write_claim(self.connection,entity_id=unknown,facet='account_role',value='seller',source='script:lookup',evidence='同名搜索命中') self.connection.commit() data=q_index(WebContract(self.db),'performers',limit=1,category='blogger') self.assertEqual(data['items'],[]) self.assertFalse(data['has_more']) self.assertEqual(data['categories'],{}) unclassified=q_index(WebContract(self.db),'performers') self.assertEqual([row['entity_id'] for row in unclassified['items']],[unknown]) self.assertEqual(unclassified['items'][0]['identity_labels'],[]) self.assertEqual(q_index(WebContract(self.db),'creators',category='seller')['items'][0]['entity_id'],known) self.assertEqual(q_index(WebContract(self.db),'creators',category="person' OR 1=1")['items'],[]) def test_personal_accounts_publishers_and_animation_have_distinct_categories(self): _,person=self.entity('Personal Account') self.claim(person,'identity','person') _,publisher=self.entity('Publishing Account') self.claim(publisher,'identity','account') self.claim(publisher,'account_role','publisher') _,animation=self.entity('Animation Account') self.claim(animation,'identity','account') self.claim(animation,'account_role','publisher') self.claim(animation,'occupation','animator') _,collection=self.entity('Resource Collection') self.claim(collection,'identity','collection') self.claim(collection,'occupation','content_creator') self.connection.commit() contract=WebContract(self.db) self.assertCountEqual([r['entity_id'] for r in q_index(contract,'performers',category='blogger')['items']], [person,publisher]) self.assertEqual(q_entity(contract,{'kind':'creator','name':'Resource Collection'})['identity_labels'],[]) self.assertNotIn(collection,[r['entity_id'] for r in q_index(contract,'performers')['items']]) self.assertEqual(q_index(contract,'performers',category='animation')['items'][0]['entity_id'],animation) def test_existing_jav_directory_identity_is_shared_by_profiles_and_paginated_filters(self): expected = [] for provider in sorted(classification.JAV_DIRECTORIES): _, entity_id = self.entity(provider, kind='performer') self.connection.execute('INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id) ' 'VALUES(?,?,?,?)', (entity_id,provider,'performer',str(entity_id))) expected.append(entity_id) self.connection.commit() before = self.connection.total_changes for provider in sorted(classification.JAV_DIRECTORIES): profile = q_entity(WebContract(self.db),{'kind':'performer','name':provider}) self.assertEqual(profile['identity_labels'],['女优']) self.assertEqual(classification.classifications(self.connection,[profile['id']])[profile['id']],[]) found = [] for offset in range(len(expected)): page = q_index(WebContract(self.db),'performers',category='japanese_av',limit=1,offset=offset) found.extend(row['entity_id'] for row in page['items']) self.assertEqual(page['categories'],{'japanese_av':len(expected)}) self.assertCountEqual(found,expected) self.assertEqual(self.connection.total_changes,before) def test_collection_refs_general_directories_and_candidates_do_not_assert_jav_identity(self): for provider, external_kind in [('minnano-av','production'),('babepedia','performer'), ('stash','performer'),('r18','performer_name'),('kmib','performer')]: _, entity_id = self.entity(provider, kind='performer') self.connection.execute('INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id) ' 'VALUES(?,?,?,?)', (entity_id,provider,external_kind,str(entity_id))) classification.write_claim(self.connection,entity_id=entity_id,facet='market',value='japanese_av', source='script:search',evidence='未核验同名搜索') self.connection.commit() data = q_index(WebContract(self.db),'performers') self.assertEqual(len(data['items']),5) self.assertTrue(all(row['identity_labels']==[] for row in data['items'])) self.assertEqual(q_index(WebContract(self.db),'performers',category='japanese_av')['items'],[]) def test_category_counts_respect_search_and_visible_works_without_being_limited_to_one_page(self): _, seller = self.entity('Seller Match') self.claim(seller,'account_role','seller') self.entity('Creator Match'); self.entity('Outside') asset_id, hidden = self.entity('Vanished Match') self.connection.execute("UPDATE asset SET disposal='vanished' WHERE id=?",(asset_id,)) self.connection.commit() data = q_index(WebContract(self.db),'creators',q='Match',limit=1,category='seller') self.assertEqual(data['categories'],{'seller':1}) self.assertEqual([row['entity_id'] for row in data['items']],[seller]) def test_fc2_release_does_not_determine_performer_occupation(self): asset_id, entity_id = self.entity('Known Person',kind='performer') self.claim(entity_id,'market','japanese_av') self.connection.execute("UPDATE asset SET code='FC2-PPV-1234567',region='west' WHERE id=?",(asset_id,)) self.connection.commit() for category in ('japanese_av','western'): page = q_index(WebContract(self.db),'performers',category=category,limit=1) self.assertEqual(page['categories'],{'japanese_av':1,'western':1}) self.assertEqual(page['items'][0]['identity_labels'],['女优','西方']) self.assertEqual(page['items'][0]['identity_categories'],['japanese_av','western']) self.assertEqual(q_index(WebContract(self.db),'performers',category='amateur')['items'],[]) self.connection.execute("UPDATE asset SET disposal='vanished' WHERE id=?",(asset_id,)) self.connection.commit() self.assertEqual(classification.summaries(self.connection,[entity_id])[entity_id]['identity_labels'],['女优']) def test_amateur_scene_tag_does_not_classify_a_jav_performer_as_amateur(self): asset_id, entity_id = self.entity('JAV Performer',kind='performer') self.claim(entity_id,'market','japanese_av') self.connection.execute("UPDATE asset SET code='SIRO-5333' WHERE id=?",(asset_id,)) upsert_asset_entity(self.connection,kind='tag',name='素人',asset_id=asset_id, role='tag',source='test:scene-tag') self.connection.commit() contract=WebContract(self.db) profile=q_entity(contract,{'kind':'performer','name':'JAV Performer'}) self.assertEqual(profile['identity_labels'],['女优']) self.assertEqual(q_index(contract,'performers',category='amateur')['items'],[]) self.assertEqual(q_index(contract,'performers')['categories'],{'japanese_av':1}) def work(self,name,code=None,*,tag=None,disposal=None): """给已有出演者再挂一部作品。""" asset_id=self.connection.execute("INSERT INTO asset(location,path,name,medium,code,disposal) VALUES('local',?,?,'video',?,?)", (f'B:\\作品\\{name}\\{code or "clip"}-{self.connection.execute("SELECT count(*) FROM asset").fetchone()[0]}.mp4', 'clip.mp4',code,disposal)).lastrowid upsert_asset_entity(self.connection,kind='performer',name=name,asset_id=asset_id,role='performer',source='legacy:asset') if tag: upsert_asset_entity(self.connection,kind='tag',name=tag,asset_id=asset_id,role='tag',source='test:scene-tag') return asset_id def test_amateur_identity_follows_trusted_claims_or_all_amateur_releases(self): _, confirmed = self.entity('Nonprofessional Cast',kind='performer') self.claim(confirmed,'occupation','amateur_performer') fc2_id, fc2_only = self.entity('FC2 Cast',kind='performer') self.connection.execute("UPDATE asset SET code='FC2-PPV-1234567' WHERE id=?",(fc2_id,)) self.work('FC2 Cast',tag='素人') self.work('FC2 Cast',disposal='vanished') mixed_id, _ = self.entity('Mixed Cast',kind='performer') self.connection.execute("UPDATE asset SET code='FC2-PPV-7654321' WHERE id=?",(mixed_id,)) self.work('Mixed Cast','ABP-001') _, guessed = self.entity('Guessed Cast',kind='performer') classification.write_claim(self.connection,entity_id=guessed,facet='occupation', value='amateur_performer',source='script:search',evidence='未核验推断') jav_id, jav = self.entity('Known JAV Cast',kind='performer') self.connection.execute("UPDATE asset SET code='FC2-PPV-1111111' WHERE id=?",(jav_id,)) self.claim(jav,'market','japanese_av') self.connection.commit() contract=WebContract(self.db) page=q_index(contract,'performers',category='amateur') self.assertEqual(sorted(item['entity_id'] for item in page['items']),sorted([confirmed,fc2_only])) self.assertEqual(page['categories']['amateur'],2) self.assertEqual(q_entity(contract,{'kind':'performer','name':'Mixed Cast'})['identity_labels'],[]) self.assertEqual(q_entity(contract,{'kind':'performer','name':'Guessed Cast'})['identity_labels'],[]) self.assertEqual(q_entity(contract,{'kind':'performer','name':'Known JAV Cast'})['identity_labels'],['女优']) self.claim(confirmed,'occupation','adult_performer') self.claim(fc2_only,'market','western_adult') self.connection.commit() self.assertEqual(q_index(contract,'performers',category='amateur')['items'],[]) def test_amateur_tags_from_the_vision_model_do_not_classify_a_performer(self): asset_id, styled = self.entity('Styled Cast',kind='performer') self.connection.execute("INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES(?,'素人',0.6,'vision_creator')",(asset_id,)) for source in ('vision_creator','vision_creator_review'): upsert_asset_entity(self.connection,kind='tag',name='素人',asset_id=asset_id,role='tag',source=source) self.connection.commit() self.assertEqual(classification.summaries(self.connection,[styled])[styled]['identity_categories'],[]) upsert_asset_entity(self.connection,kind='tag',name='素人',asset_id=asset_id,role='tag',source='name') self.connection.commit() self.assertEqual(classification.summaries(self.connection,[styled])[styled]['identity_categories'],['amateur']) def test_animation_author_appears_in_artist_directory_with_its_own_category(self): _, animator = self.entity('Animator') self.claim(animator,'occupation','animator') _, artist = self.entity('Artist') self.claim(artist,'occupation','artist') self.connection.commit() page = q_index(WebContract(self.db),'performers',category='animation') self.assertEqual([row['entity_id'] for row in page['items']],[animator]) self.assertEqual(page['categories'],{'animation':1}) self.assertEqual(q_index(WebContract(self.db),'creators')['items'],[]) def test_real_accounts_share_artist_directory_and_keep_avatars_and_profile_routes(self): _, actor = self.entity('Actor', kind='performer') _, blogger = self.entity('Blogger') _, seller = self.entity('Seller') _, animator = self.entity('Animator') self.claim(seller,'account_role','seller') self.claim(animator,'occupation','animator') self.connection.commit() artists = q_index(WebContract(self.db),'performers') self.assertCountEqual([row['entity_id'] for row in artists['items']],[actor,blogger,animator]) self.assertEqual({row['entity_id']:row['entity_kind'] for row in artists['items']},{actor:'performer',blogger:'creator',animator:'creator'}) separate = q_index(WebContract(self.db),'creators') self.assertCountEqual([row['entity_id'] for row in separate['items']],[seller]) def test_person_account_can_belong_to_blogger_and_seller_directories(self): _, person = self.entity('Person Account') self.claim(person,'identity','person') self.claim(person,'account_role','seller') self.connection.commit() contract = WebContract(self.db) self.assertEqual(q_index(contract,'performers')['items'][0]['entity_id'],person) self.assertEqual(q_index(contract,'creators')['items'][0]['entity_id'],person) self.assertEqual(q_entity(contract,{'kind':'creator','name':'Person Account'})['identity_labels'],['网黄博主','卖家']) def test_western_cast_occupation_does_not_assert_a_japanese_av_career(self): _, entity_id = self.entity('Western Performer',kind='performer') self.claim(entity_id,'occupation','adult_performer') self.claim(entity_id,'market','western_adult') self.connection.commit() profile = q_entity(WebContract(self.db),{'kind':'performer','name':'Western Performer'}) self.assertEqual(profile['identity_labels'],['西方']) self.assertEqual(q_index(WebContract(self.db),'performers',category='japanese_av')['items'],[]) def test_script_cannot_approve_or_supply_unvalidated_values(self): _,entity_id=self.entity('Known Person') for patch in [{'status':'approved'}, {'status':'observed','source_url':'http://example.test'}]: with self.assertRaises(ValueError): classification.write_claim(self.connection,entity_id=entity_id,facet='identity',value='person',source='script:lookup',evidence='推断',**patch) with self.assertRaises(ValueError): self.claim(entity_id,'occupation','unknown_profession') with self.assertRaisesRegex(ValueError,'代码判据'): classification.write_claim(self.connection,entity_id=entity_id,facet='identity',value='person', source='script:identity-research',source_url='https://publisher.test/profile', evidence='研究清单判断',status='observed',confidence=1) def test_only_code_sources_make_observed_claims_trusted(self): _,research=self.entity('Research Person',kind='performer') _,parsed=self.entity('Parsed Person',kind='performer') self.connection.execute("INSERT INTO entity_classification VALUES(?,'market','japanese_av'," "'script:identity-research','https://publisher.test/a','研究清单判断','observed',1,'t')",(research,)) self.claim(parsed,'market','japanese_av') self.connection.commit() page=q_index(WebContract(self.db),'performers',category='japanese_av') self.assertEqual([row['entity_id'] for row in page['items']],[parsed]) self.assertEqual(page['categories'],{'japanese_av':1}) def test_research_observations_downgrade_to_candidates_and_code_facts_stay(self): _,research=self.entity('Research Person',kind='performer') _,parsed=self.entity('Parsed Person',kind='performer') self.connection.execute("INSERT INTO entity_classification VALUES(?,'market','japanese_av'," "'script:identity-research','https://publisher.test/a','研究清单判断','observed',0.8,'t')",(research,)) self.connection.execute("INSERT INTO entity_identity_link VALUES(?,?,'same_person'," "'script:identity-research','https://publisher.test/a','研究清单判断','observed','t')",(research,parsed)) self.claim(parsed,'market','japanese_av') plan=classification.untrusted_observed(self.connection) self.assertEqual([(row['entity_id'],row['source']) for row in plan['entity_classification']], [(research,'script:identity-research')]) self.assertEqual(len(plan['entity_identity_link']),1) self.assertEqual(classification.downgrade_untrusted_observed(self.connection), {'entity_classification':1,'entity_identity_link':1}) rows=self.connection.execute('SELECT entity_id,status,confidence,evidence FROM entity_classification ORDER BY entity_id').fetchall() self.assertEqual([tuple(row) for row in rows], [(research,'candidate',0.8,'研究清单判断'),(parsed,'observed',1.0,'发行方资料')]) self.assertEqual(classification.untrusted_observed(self.connection), {'entity_classification':[],'entity_identity_link':[]}) def test_name_wiki_work_list_through_the_research_list_lands_as_candidate_release(self): _,entity_id=self.entity('COSH こすっち') classification.write_claim(self.connection,entity_id=entity_id,facet='identity',value='unknown', source=research.SOURCE,evidence='公开身份来源未取得') keys=research.lookup_keys('COSH こすっち',['こすっち']) self.assertEqual(keys,['COSH こすっち','こすっち']) self.assertEqual(research.lookup_keys('secret_japan'),['secret_japan']) listed={SOUGOU+'%A4%B3%A4%B9%A4%C3%A4%C1':_work_table('八ッ橋さい子','本多由奈','北川ゆず')} finding=research.wiki_finding(entity_id,'COSH こすっち',keys,{'sougouwiki':_Pages(listed),'av_neme':_Pages()}) with self.connection: research.apply(self.connection,research.plan(self.connection,[finding])) claims={(row['value'],row['status']) for row in self.connection.execute( "SELECT value,status FROM entity_classification WHERE entity_id=? AND facet='identity'",(entity_id,))} self.assertEqual([claim['status'] for claim in finding['claims']],['observed','rejected']) self.assertEqual(claims,{('release','candidate'),('unknown','rejected')}) def test_name_wiki_lookup_records_pages_asked_and_leaves_failed_fetches_unfetched(self): missing=research.wiki_finding(7,'lucky',['lucky'],{'sougouwiki':_Pages(),'av_neme':_Pages()}) self.assertEqual([(claim['value'],claim['status']) for claim in missing['claims']],[('unknown','candidate')]) self.assertIn('sougouwiki「lucky」(页不存在)',missing['claims'][0]['evidence']) self.assertIn('av_neme「lucky」(页不存在)',missing['claims'][0]['evidence']) broken=_Pages(failing=[SOUGOU+'lucky']) self.assertIsNone(research.wiki_finding(7,'lucky',['lucky'],{'sougouwiki':broken,'av_neme':_Pages()})) few=_Pages({SOUGOU+'lucky':_work_table('八ッ橋さい子','本多由奈')}) finding=research.wiki_finding(7,'lucky',['lucky'],{'sougouwiki':few,'av_neme':_Pages()}) self.assertIn('sougouwiki「lucky」(不是作品一览)',finding['claims'][0]['evidence']) def test_cast_role_repair_and_restore_preserve_all_business_fields(self): asset_id,entity_id=self.entity('Known Person') self.connection.execute('UPDATE asset SET play_count=7,rating=4 WHERE id=?',(asset_id,));self.connection.commit() before=dict(self.connection.execute('SELECT * FROM asset WHERE id=?',(asset_id,)).fetchone()) frozen=research.plan(self.connection,[self.finding('Known Person',entity_id)]) self.assertEqual(len(frozen[0]['cast']),1) with self.connection: receipt=research.apply(self.connection,frozen) self.assertEqual(self.connection.execute('SELECT role FROM asset_entity WHERE asset_id=?',(asset_id,)).fetchone()[0],'performer') self.assertEqual(self.connection.execute('SELECT group_concat(DISTINCT status) FROM entity_classification').fetchone()[0],'candidate') self.assertEqual(self.connection.execute('SELECT group_concat(status) FROM entity_identity_link').fetchone()[0],'candidate') self.assertEqual(classification.related_identities(self.connection,entity_id),[]) with self.connection: research.restore(self.connection,receipt) after=dict(self.connection.execute('SELECT * FROM asset WHERE id=?',(asset_id,)).fetchone()) before.pop('mutation_revision');after.pop('mutation_revision') self.assertEqual(before,after) self.assertEqual(self.connection.execute('SELECT count(*) FROM entity').fetchone()[0],1) self.assertEqual(self.connection.execute('SELECT count(*) FROM entity_identity_link').fetchone()[0],0) self.assertEqual(self.connection.execute('PRAGMA foreign_key_check').fetchall(),[]) def test_stale_plan_and_history_changes_block_repair_or_restore(self): asset_id,entity_id=self.entity('Known Person') frozen=research.plan(self.connection,[self.finding('Known Person',entity_id)]) self.connection.execute('UPDATE asset SET creator=? WHERE id=?',('User choice',asset_id));self.connection.commit() with self.assertRaisesRegex(ValueError,'已过期'): with self.connection: research.apply(self.connection,frozen) frozen=research.plan(self.connection,[self.finding('Known Person',entity_id)]) with self.connection: receipt=research.apply(self.connection,frozen) self.connection.execute('UPDATE asset SET play_count=9 WHERE id=?',(asset_id,));self.connection.commit() with self.assertRaisesRegex(ValueError,'后续改动'): with self.connection: research.restore(self.connection,receipt) def test_manual_creator_relation_is_not_reclassified_as_cast(self): asset_id,entity_id=self.entity('Known Person') self.connection.execute("UPDATE asset_entity SET source='user:manual' WHERE asset_id=?",(asset_id,));self.connection.commit() self.assertEqual(research.plan(self.connection,[self.finding('Known Person',entity_id)])[0]['cast'],[]) def test_entity_merge_transfers_classification_with_foreign_keys_disabled(self): _,target=self.entity('Known Person');_,source=self.entity('Old Name') self.claim(source,'occupation','model');self.connection.commit() with self.connection: merge_entity(self.connection,target_id=target,source_id=source,source_name='Old Name',alias_source='user:review') self.assertEqual(classification.classifications(self.connection,[target])[target][0]['label'],'模特') self.assertEqual(self.connection.execute('PRAGMA foreign_key_check').fetchall(),[]) def part(self, number, code='FC2-PPV-3312576', disposal=None): name=f'{code}-{number}.mp4' cursor=self.connection.execute( "INSERT INTO asset(location,path,name,medium,code,size,duration,disposal) " "VALUES('local',?,?,'video',?,100,600,?)", (f'B:\\番号\\{code}\\{name}',name,code,disposal)) for kind, label in [('performer','Cast Person'),('performer','Costar'),('studio','FC2 Studio'),('tag','Test Tag')]: upsert_asset_entity(self.connection,kind=kind,name=label,asset_id=cursor.lastrowid,role=kind,source='test:cast') self.connection.commit() return cursor.lastrowid def test_merge_deduplicates_identical_source_facts_and_keeps_latest_check_time(self): _, target = self.entity('Target'); _, source = self.entity('Source') for entity_id, checked in ((source, '2026-10-07T01:00:00+00:00'), (target, '2026-10-06T01:00:00+00:00')): classification.write_claim(self.connection, entity_id=entity_id, facet='identity', value='person', source='source:official', evidence='同一份署名', source_url='https://publisher.test/person', status='observed', confidence=1, checked_at=checked) self.connection.commit() with self.connection: merge_entity(self.connection, target_id=target, source_id=source, source_name='Source', alias_source='user:review') rows = classification.classifications(self.connection, [target])[target] self.assertEqual(len(rows), 1) self.assertEqual(rows[0]['checked_at'], '2026-10-07T01:00:00+00:00') def test_merge_rejects_different_facts_from_the_same_source(self): _, target = self.entity('Target'); _, source = self.entity('Source') for entity_id, evidence in ((source, '记录甲'), (target, '记录乙')): classification.write_claim(self.connection, entity_id=entity_id, facet='identity', value='person', source='source:official', evidence=evidence, status='observed', confidence=1, source_url='https://publisher.test/person') self.connection.commit() with self.assertRaisesRegex(ValueError, '来源冲突'), self.connection: merge_entity(self.connection, target_id=target, source_id=source, source_name='Source', alias_source='user:review') self.assertEqual(self.connection.execute('SELECT count(*) FROM entity WHERE id IN (?,?)', (target, source)).fetchone()[0], 2) def test_fc2_collection_counts_once_in_profiles_rosters_tags_and_search(self): for number in range(1,20): self.part(number) contract=WebContract(self.db) profile=q_entity(contract,{'kind':'performer','name':'Cast Person'}) self.assertEqual(profile['asset_count'],1) self.assertEqual(profile['related_performers'][0]['n'],1) self.assertEqual(profile['tags'][0]['n'],1) for kind in ('performers','studios','tags'): self.assertEqual(q_index(contract,kind)['items'][0]['n'],1) suggest=q_suggest(contract,'Cast Person',kinds=['performer']) self.assertEqual(suggest['groups'][0]['items'][0]['n'],1) page=q_items(contract,{'performer':'Cast Person','limit':'5'}) self.assertEqual(page['work_total'],1) self.assertEqual(page['total'],19) self.assertEqual(len(page['items']),5) self.assertEqual(page['items'][0]['part_group']['count'],19) self.assertEqual(self.connection.execute('SELECT count(*) FROM asset').fetchone()[0],19) def test_fc2_work_counts_keep_visibility_and_invalidate_with_ledger_changes(self): self.part(1);self.part(3,disposal='trash');self.part(4,disposal='vanished') contract=WebContract(self.db) self.assertEqual(q_entity(contract,{'kind':'performer','name':'Cast Person'})['asset_count'],1) self.part(1,code='FC2-PPV-7777777') self.assertEqual(q_entity(contract,{'kind':'performer','name':'Cast Person'})['asset_count'],2) self.assertEqual(q_items(contract,{'performer':'Cast Person'})['work_total'],2) self.assertEqual(self.connection.execute('SELECT count(*) FROM asset_entity').fetchone()[0],16) def test_ambiguous_fc2_encodes_are_counted_as_individual_files(self): self.part(1);self.part(2) self.part('1-1080p') profile=q_entity(WebContract(self.db),{'kind':'performer','name':'Cast Person'}) self.assertEqual(profile['asset_count'],3) self.assertEqual(q_items(WebContract(self.db),{'performer':'Cast Person'})['work_total'],3) def test_cross_role_merge_routes_only_explicitly_marked_aliases(self): _, target=self.entity('Known Person',kind='performer') _, source=self.entity('Old Account Name') with self.connection: merge_entity(self.connection,target_id=target,source_id=source, source_name='Old Account Name',alias_source='user:identity-merge:creator') self.assertEqual(q_entity(WebContract(self.db),{'kind':'creator','name':'Old Account Name'}), {'redirect':{'id':target,'kind':'performer','name':'Known Person'}}) self.assertEqual(q_entity(WebContract(self.db),{'kind':'studio','name':'Known Person'}),{'error':'not found'}) self.assertEqual(self.connection.execute('PRAGMA foreign_key_check').fetchall(),[]) def test_matching_names_without_a_merge_are_distinct_identities(self): self.entity('Known Person',kind='performer') self.assertEqual(q_entity(WebContract(self.db),{'kind':'creator','name':'Known Person'}),{'error':'not found'})