"""浏览分类、来源保护、身份衔接与分页的行为回归。"""
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 (''
'| NO | TITLE | ACTRESS |
' + rows + '
').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'})