Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 241 additions & 16 deletions packtools/sps/validation/app_group.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from packtools.sps.models.app_group import XmlAppGroup
from packtools.sps.validation.utils import format_response
from packtools.sps.validation.utils import build_response


class AppValidation:
Expand All @@ -9,36 +9,54 @@ def __init__(self, xmltree, params):
self.params = params

def validate(self):
"""
Executa todas as validações para <app> e <app-group>.

Yields:
dict: Resultados de validação
"""
yield from self.validate_app_existence()
yield from self.validate_app_id()
yield from self.validate_app_label()
yield from self.validate_app_group_wrapper()
yield from self.validate_media_accessibility()

def validate_app_existence(self):
"""
Valida a existência de elementos <app>.

Nota: <app> é opcional segundo SciELO, mas esta validação
fornece informação útil aos editores.

Yields:
dict: Resultado de validação (nível informativo)
"""
if not self.apps:
yield format_response(
title="<app>",
parent="article",
parent_id=None,
parent_article_type=self.xmltree.get("article-type"),
parent_lang=self.xmltree.get(
"{http://www.w3.org/XML/1998/namespace}lang"
),
yield build_response(
title="<app> element",
parent={
"parent": "article",
"parent_id": None,
"parent_article_type": self.xmltree.get("article-type"),
"parent_lang": self.xmltree.get("{http://www.w3.org/XML/1998/namespace}lang"),
},
item="app-group",
sub_item="app",
validation_type="exist",
is_valid=False,
expected="<app> element",
obtained=None,
advice="Consider adding an <app> element to include additional content such as supplementary materials or appendices.",
advice="Consider adding an <app> element to include additional content such as appendices.",
data=None,
error_level=self.params["app_existence_error_level"],
advice_text="Consider adding an <app> element to include additional content such as appendices.",
advice_params={}
)
else:
for app in self.apps:
yield format_response(
title="<app>",
parent=app.get("parent"),
parent_id=app.get("parent_id"),
parent_article_type=app.get("parent_article_type"),
parent_lang=app.get("parent_lang"),
yield build_response(
title="<app> element",
parent=app,
item="app-group",
sub_item="app",
validation_type="exist",
Expand All @@ -48,4 +66,211 @@ def validate_app_existence(self):
advice=None,
data=app,
error_level=self.params["app_existence_error_level"],
advice_text=None,
advice_params=None
Comment on lines +69 to +70
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rossi-Luciano advice nunca deveria ser None, sempre tem que ter alguma instrução para o usuário mesmo que seja óbvia, no entanto, se for "óbvia" pode ser melhorada, por exemplo, no lugar de dizer complete com o autor, explique use a tag X para representar o nome do autor e Y para representar o sobrenome, use ... para ....

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@robertatakenaka esse caso se refere ao "sucesso" da validação, ou seja, existe <app> e serão geradas respostas identificando cada ocorrência da tag.

)

def validate_app_id(self):
"""
Valida presença obrigatória do atributo @id em <app>.

Regra SciELO: "Atributo obrigatório para <app>: @id"

Yields:
dict: Resultado de validação (CRITICAL se ausente)
"""
for app in self.apps:
app_id = app.get("id")
is_valid = bool(app_id)

yield build_response(
title="<app> @id attribute",
parent=app,
item="app",
sub_item="@id",
validation_type="exist",
is_valid=is_valid,
expected="@id attribute in <app>",
obtained=app_id if is_valid else None,
advice='Add @id attribute to <app>. Example: <app id="app1">',
data=app,
error_level=self.params["app_id_error_level"],
advice_text='Add @id attribute to <app>. Example: <app id="{example_id}">',
advice_params={
"example_id": "app1"
}
)

def validate_app_label(self):
"""
Valida presença obrigatória do elemento <label> em <app>.

Regra SciELO: "<app> exigem o elemento <label> como título do apêndice ou anexo"
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar error in Portuguese docstring. The verb "exigem" (plural) should be "exige" (singular) to agree with the singular subject "". The correct sentence should be: " exige o elemento como título do apêndice ou anexo"

Suggested change
Regra SciELO: "<app> exigem o elemento <label> como título do apêndice ou anexo"
Regra SciELO: "<app> exige o elemento <label> como título do apêndice ou anexo"

Copilot uses AI. Check for mistakes.

Yields:
dict: Resultado de validação (CRITICAL se ausente)
"""
for app in self.apps:
label = app.get("label")
is_valid = bool(label)

yield build_response(
title="<app> <label> element",
parent=app,
item="app",
sub_item="label",
validation_type="exist",
is_valid=is_valid,
expected="<label> element in <app>",
obtained=label if is_valid else None,
advice=f'Add <label> element to <app id="{app.get("id", "?")}">. '
f'Example: <app id="{app.get("id", "app1")}"><label>Appendix 1</label></app>',
data=app,
Comment on lines +126 to +128
Copy link
Member

@robertatakenaka robertatakenaka Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rossi-Luciano user variáveis no lugar de app.get... para evitar problemas com aspas incompatíveis. Além disso esta prática deixa o código mais enxuto e evita ter app.get repetidas vezes

error_level=self.params["app_label_error_level"],
advice_text='Add <label> element to <app id="{app_id}">. '
'Example: <app id="{app_id}"><label>{example_label}</label></app>',
advice_params={
"app_id": app.get("id", "?"),
"example_label": "Appendix 1"
}
)

def validate_app_group_wrapper(self):
"""
Valida que <app> está dentro de <app-group>.

Regra SciELO: "O elemento <app-group> deve sempre ser usado como agrupador
do elemento <app>, mesmo se houver somente uma ocorrência deste último"

Nota: Esta validação verifica se há <app> órfão (fora de <app-group>).

Yields:
dict: Resultado de validação (CRITICAL se órfão encontrado)
"""
# Buscar <app> que estão diretamente em <back>, sem <app-group>
orphan_apps = self.xmltree.xpath(".//back/app")

if orphan_apps:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rossi-Luciano acho que uma coisa não anula a outra. Acho que na pior das hipóteses, pode haver órfãs e app dentro de app-group. Que tal dividir em 2 validações separadas e fazer uma chamas às 2 funções?

for orphan in orphan_apps:
app_id = orphan.get("id", "unknown")

yield build_response(
title="<app-group> wrapper required",
parent={
"parent": "article",
"parent_id": None,
"parent_article_type": self.xmltree.get("article-type"),
"parent_lang": self.xmltree.get("{http://www.w3.org/XML/1998/namespace}lang"),
},
item="app",
sub_item="app-group",
validation_type="wrapper",
is_valid=False,
expected="<app> inside <app-group>",
obtained=f"<app id='{app_id}'> directly in <back>",
advice=f'Wrap <app id="{app_id}"> with <app-group>. '
f'Example: <back><app-group><app id="{app_id}">...</app></app-group></back>',
data={"app_id": app_id},
error_level=self.params["app_group_wrapper_error_level"],
advice_text='Wrap <app id="{app_id}"> with <app-group>. '
'Example: <back><app-group><app id="{app_id}">...</app></app-group></back>',
advice_params={
"app_id": app_id
}
)
else:
# Verificar se há múltiplos <app-group> (deve ser 0 ou 1)
app_groups = self.xmltree.xpath(".//back/app-group")

if len(app_groups) > 1:
yield build_response(
title="Single <app-group> allowed",
parent={
"parent": "article",
"parent_id": None,
"parent_article_type": self.xmltree.get("article-type"),
"parent_lang": self.xmltree.get("{http://www.w3.org/XML/1998/namespace}lang"),
},
item="back",
sub_item="app-group",
validation_type="occurrence",
is_valid=False,
expected="Zero or one <app-group> in <back>",
obtained=f"{len(app_groups)} <app-group> elements found",
advice=f'Merge all {len(app_groups)} <app-group> elements into a single <app-group>.',
data={"count": len(app_groups)},
error_level=self.params["app_group_occurrence_error_level"],
advice_text='Merge all {count} <app-group> elements into a single <app-group>.',
advice_params={
"count": len(app_groups)
}
)

Comment on lines +181 to +208
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for multiple app-group elements is nested inside the else block (line 181), which means it only executes when there are no orphan apps. This creates a logic error where if there are orphan apps, the multiple app-group validation is skipped entirely.

Both validations should run independently:

  1. Check for orphan apps (apps not inside app-group)
  2. Check for multiple app-group elements (maximum of 1 allowed)

The multiple app-group check should be moved outside the if-else block so it always executes, regardless of whether orphan apps exist.

Suggested change
else:
# Verificar se há múltiplos <app-group> (deve ser 0 ou 1)
app_groups = self.xmltree.xpath(".//back/app-group")
if len(app_groups) > 1:
yield build_response(
title="Single <app-group> allowed",
parent={
"parent": "article",
"parent_id": None,
"parent_article_type": self.xmltree.get("article-type"),
"parent_lang": self.xmltree.get("{http://www.w3.org/XML/1998/namespace}lang"),
},
item="back",
sub_item="app-group",
validation_type="occurrence",
is_valid=False,
expected="Zero or one <app-group> in <back>",
obtained=f"{len(app_groups)} <app-group> elements found",
advice=f'Merge all {len(app_groups)} <app-group> elements into a single <app-group>.',
data={"count": len(app_groups)},
error_level=self.params["app_group_occurrence_error_level"],
advice_text='Merge all {count} <app-group> elements into a single <app-group>.',
advice_params={
"count": len(app_groups)
}
)
# Verificar se há múltiplos <app-group> (deve ser 0 ou 1)
app_groups = self.xmltree.xpath(".//back/app-group")
if len(app_groups) > 1:
yield build_response(
title="Single <app-group> allowed",
parent={
"parent": "article",
"parent_id": None,
"parent_article_type": self.xmltree.get("article-type"),
"parent_lang": self.xmltree.get("{http://www.w3.org/XML/1998/namespace}lang"),
},
item="back",
sub_item="app-group",
validation_type="occurrence",
is_valid=False,
expected="Zero or one <app-group> in <back>",
obtained=f"{len(app_groups)} <app-group> elements found",
advice=f'Merge all {len(app_groups)} <app-group> elements into a single <app-group>.',
data={"count": len(app_groups)},
error_level=self.params["app_group_occurrence_error_level"],
advice_text='Merge all {count} <app-group> elements into a single <app-group>.',
advice_params={
"count": len(app_groups)
}
)

Copilot uses AI. Check for mistakes.
def validate_media_accessibility(self):
"""
Valida acessibilidade de elementos <media> dentro de <app>.

Regra SciELO: "Para acessibilidade recomenda-se que vídeos e áudios venham
com sua descrição em <alt-text> e/ou <long-desc> mais a transcrição do
conteúdo na seção <sec sec-type='transcript'>"

Yields:
dict: Resultados de validação para cada mídia
"""
for app in self.apps:
media_list = app.get("media", [])

for media in media_list:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rossi-Luciano será que não é possível reuso de validação? (no caso MediaValidation ?)

# Validar presença de alt-text OU long-desc
alt_text = media.get("alt_text")
long_desc = media.get("long_desc")
has_description = bool(alt_text or long_desc)

media_href = media.get("xlink_href", "unknown")

yield build_response(
title="Media accessibility: alt-text or long-desc",
parent=app,
item="media",
sub_item="alt-text/long-desc",
validation_type="exist",
is_valid=has_description,
expected="<alt-text> or <long-desc> in <media>",
obtained=f"alt-text: {bool(alt_text)}, long-desc: {bool(long_desc)}",
advice=f'Add <alt-text> or <long-desc> to <media xlink:href="{media_href}"> '
f'for accessibility. Example: <media ...><alt-text>Description</alt-text></media>',
data=media,
error_level=self.params["media_alt_text_error_level"],
advice_text='Add <alt-text> or <long-desc> to <media xlink:href="{media_href}"> '
'for accessibility. Example: <media ...><alt-text>Description</alt-text></media>',
advice_params={
"media_href": media_href
}
)

# Validar referência a transcrição
xref_sec_rid = media.get("xref_sec_rid")
has_transcript_ref = bool(xref_sec_rid)

yield build_response(
title="Media accessibility: transcript reference",
parent=app,
item="media",
sub_item="xref[@ref-type='sec']",
validation_type="exist",
is_valid=has_transcript_ref,
expected="<xref ref-type='sec' rid='...'> referencing <sec sec-type='transcript'>",
obtained=xref_sec_rid if has_transcript_ref else None,
advice=f'Add <xref ref-type="sec" rid="TR1"/> inside <media xlink:href="{media_href}"> '
f'to reference transcript section. '
f'Example: <media ...><xref ref-type="sec" rid="TR1"/></media>',
data=media,
error_level=self.params["media_transcript_error_level"],
advice_text='Add <xref ref-type="sec" rid="{transcript_id}"/> inside <media xlink:href="{media_href}"> '
'to reference transcript section. '
'Example: <media ...><xref ref-type="sec" rid="{transcript_id}"/></media>',
advice_params={
"media_href": media_href,
"transcript_id": "TR1"
}
)
10 changes: 8 additions & 2 deletions packtools/sps/validation_rules/app_group_rules.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
{
"app_group_rules": {
"app_existence_error_level": "WARNING"
"app_existence_error_level": "WARNING",
"app_id_error_level": "CRITICAL",
"app_label_error_level": "CRITICAL",
"app_group_wrapper_error_level": "CRITICAL",
"app_group_occurrence_error_level": "CRITICAL",
"media_alt_text_error_level": "ERROR",
"media_transcript_error_level": "WARNING"
}
}
}
Loading
Loading