Added the ASOP.

master
Pacman Ghost 3 years ago
parent 41476c80e5
commit 35f31b12ba
  1. 4
      .pylintrc
  2. 3
      asl_rulebook2/webapp/__init__.py
  3. 107
      asl_rulebook2/webapp/asop.py
  4. 5
      asl_rulebook2/webapp/main.py
  5. 1
      asl_rulebook2/webapp/run_server.py
  6. 58
      asl_rulebook2/webapp/search.py
  7. 7
      asl_rulebook2/webapp/startup.py
  8. 178
      asl_rulebook2/webapp/static/ASOP.js
  9. 60
      asl_rulebook2/webapp/static/Accordian.js
  10. 13
      asl_rulebook2/webapp/static/ContentPane.js
  11. 33
      asl_rulebook2/webapp/static/MainApp.js
  12. 83
      asl_rulebook2/webapp/static/NavPane.js
  13. 5
      asl_rulebook2/webapp/static/SearchPane.js
  14. 21
      asl_rulebook2/webapp/static/SearchResult.js
  15. 4
      asl_rulebook2/webapp/static/TabbedPages.js
  16. 39
      asl_rulebook2/webapp/static/css/ASOP.css
  17. 1
      asl_rulebook2/webapp/static/css/Accordian.css
  18. 1
      asl_rulebook2/webapp/static/css/RuleInfo.css
  19. 5
      asl_rulebook2/webapp/static/css/SearchResult.css
  20. 15
      asl_rulebook2/webapp/static/css/global.css
  21. 36
      asl_rulebook2/webapp/static/utils.js
  22. 9
      asl_rulebook2/webapp/templates/index.html
  23. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/advance-1.html
  24. 12
      asl_rulebook2/webapp/tests/fixtures/asop/asop/advance-2.html
  25. 17
      asl_rulebook2/webapp/tests/fixtures/asop/asop/advancing-fire-1.html
  26. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/advancing-fire-2.html
  27. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/advancing-fire-3.html
  28. 24
      asl_rulebook2/webapp/tests/fixtures/asop/asop/asop.css
  29. 1
      asl_rulebook2/webapp/tests/fixtures/asop/asop/close-combat-0.html
  30. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/close-combat-1.html
  31. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/close-combat-2.html
  32. 5
      asl_rulebook2/webapp/tests/fixtures/asop/asop/close-combat-3.html
  33. 13
      asl_rulebook2/webapp/tests/fixtures/asop/asop/close-combat-4.html
  34. 11
      asl_rulebook2/webapp/tests/fixtures/asop/asop/defensive-fire-1.html
  35. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/defensive-fire-2.html
  36. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/defensive-fire-3.html
  37. 13
      asl_rulebook2/webapp/tests/fixtures/asop/asop/footer.html
  38. BIN
      asl_rulebook2/webapp/tests/fixtures/asop/asop/images/attacker.png
  39. BIN
      asl_rulebook2/webapp/tests/fixtures/asop/asop/images/both-players.png
  40. BIN
      asl_rulebook2/webapp/tests/fixtures/asop/asop/images/defender.png
  41. BIN
      asl_rulebook2/webapp/tests/fixtures/asop/asop/images/dyo.png
  42. 78
      asl_rulebook2/webapp/tests/fixtures/asop/asop/index.json
  43. 10
      asl_rulebook2/webapp/tests/fixtures/asop/asop/intro.html
  44. 5
      asl_rulebook2/webapp/tests/fixtures/asop/asop/movement-0.html
  45. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/movement-1.html
  46. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/movement-2.html
  47. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/movement-3.html
  48. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/movement-4.html
  49. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/movement-5.html
  50. 3
      asl_rulebook2/webapp/tests/fixtures/asop/asop/pre-game-0.html
  51. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/pre-game-1.html
  52. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/pre-game-2.html
  53. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/pre-game-3.html
  54. 7
      asl_rulebook2/webapp/tests/fixtures/asop/asop/pre-game-4.html
  55. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/pre-game-5.html
  56. 18
      asl_rulebook2/webapp/tests/fixtures/asop/asop/prep-fire-1.html
  57. 21
      asl_rulebook2/webapp/tests/fixtures/asop/asop/prep-fire-2.html
  58. 8
      asl_rulebook2/webapp/tests/fixtures/asop/asop/prep-fire-3.html
  59. 1
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rally-0.html
  60. 16
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rally-1.html
  61. 12
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rally-2.html
  62. 10
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rally-3.html
  63. 1
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rout-0.html
  64. 9
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rout-1.html
  65. 7
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rout-2.html
  66. 3
      asl_rulebook2/webapp/tests/fixtures/asop/asop/rout-3.html
  67. 4
      asl_rulebook2/webapp/tests/test_annotations.py
  68. 289
      asl_rulebook2/webapp/tests/test_asop.py
  69. 12
      asl_rulebook2/webapp/tests/test_qa.py
  70. 51
      asl_rulebook2/webapp/tests/test_search.py
  71. 38
      asl_rulebook2/webapp/tests/utils.py

@ -145,7 +145,9 @@ disable=print-statement,
global-statement,
invalid-name,
too-few-public-methods,
duplicate-code
duplicate-code,
no-else-return,
consider-using-enumerate
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option

@ -54,6 +54,9 @@ app = Flask( __name__ )
_load_config( "app.cfg", "System" )
_load_config( "site.cfg", "Site Config" )
_load_config( "debug.cfg", "Debug" )
for key, val in app.config.items():
if str( val ).isdigit():
app.config[ key ] = int( val )
# initialize logging
_fname = os.path.join( CONFIG_DIR, "logging.yaml" )

@ -0,0 +1,107 @@
""" Manage the ASOP. """
import os
from flask import jsonify, render_template_string, send_from_directory, safe_join, url_for, abort
from asl_rulebook2.webapp import app
from asl_rulebook2.webapp.utils import load_data_file
_asop = None
_asop_dir = None
_asop_section_content = None
user_css_url = None
# ---------------------------------------------------------------------
def init_asop( startup_msgs, logger ):
"""Initialize the ASOP."""
# initiailize
global _asop, _asop_dir, _asop_section_content, user_css_url
_asop, _asop_section_content = {}, {}
# get the data directory
data_dir = app.config.get( "DATA_DIR" )
if not data_dir:
return None, None
base_dir = os.path.join( data_dir, "asop/" )
if not os.path.isdir( base_dir ):
return None, None
_asop_dir = base_dir
fname = os.path.join( base_dir, "asop.css" )
if os.path.isfile( fname ):
user_css_url = url_for( "get_asop_file", path="asop.css" )
# load the ASOP index
fname = os.path.join( base_dir, "index.json" )
_asop = load_data_file( fname, "ASCOP index", False, logger, startup_msgs.error )
if not _asop:
return None, None
# load the ASOP content
for chapter in _asop.get( "chapters", [] ):
# load the chapter preamble
preamble = _render_template( chapter["chapter_id"] + "-0.html" )
if preamble:
chapter["preamble"] = preamble
# load the content for each section
for section_no, section in enumerate( chapter.get( "sections", [] ) ):
section_id = "{}-{}".format( chapter["chapter_id"], 1+section_no )
section[ "section_id" ] = section_id
content = _render_template( section_id + ".html" )
_asop_section_content[ section_id ] = content
return _asop, _asop_section_content
# ---------------------------------------------------------------------
@app.route( "/asop" )
def get_asop():
"""Return the ASOP."""
return jsonify( _asop )
@app.route( "/asop/intro" )
def get_asop_intro():
"""Return the ASOP intro."""
resp = _render_template( "intro.html" )
if not resp:
return "No ASOP intro."
return resp
@app.route( "/asop/footer" )
def get_asop_footer():
"""Return the ASOP footer."""
resp = _render_template( "footer.html" )
if not resp:
abort( 404 )
return resp
@app.route( "/asop/section/<section_id>" )
def get_asop_section( section_id ):
"""Return the specified ASOP section."""
content = _asop_section_content.get( section_id )
if not content:
abort( 404 )
return content
@app.route( "/asop/<path:path>" )
def get_asop_file( path ):
"""Return a user-defined ASOP file."""
return send_from_directory( _asop_dir, path )
# ---------------------------------------------------------------------
def _render_template( fname ):
"""Render an ASOP template."""
if not _asop_dir:
return None
fname = safe_join( _asop_dir, fname )
if not os.path.isfile( fname ):
return None
args = {
"ASOP_BASE_URL": url_for( "get_asop_file", path="" ),
}
args.update( _asop.get( "template_args", {} ) )
with open( fname, "r" ) as fp:
return render_template_string( fp.read(), **args )

@ -15,7 +15,10 @@ from asl_rulebook2.webapp.utils import parse_int
@app.route( "/" )
def main():
"""Return the main page."""
return render_template( "index.html" )
from asl_rulebook2.webapp.asop import user_css_url
return render_template( "index.html",
ASOP_CSS_URL = user_css_url
)
# ---------------------------------------------------------------------

@ -55,6 +55,7 @@ def main( bind_addr, data_dir, force_init_delay, flask_debug ):
fspecs.append( os.path.join( app.config["DATA_DIR"], "q+a/" ) )
fspecs.append( os.path.join( app.config["DATA_DIR"], "errata/" ) )
fspecs.append( os.path.join( app.config["DATA_DIR"], "annotations.json" ) )
fspecs.append( os.path.join( app.config["DATA_DIR"], "asop/" ) )
for fspec in fspecs:
fspec = os.path.abspath( os.path.join( os.path.dirname(__file__), fspec ) )
if os.path.isdir( fspec ):

@ -12,6 +12,7 @@ import logging
import traceback
from flask import request, jsonify
import lxml.html
from asl_rulebook2.utils import plural
from asl_rulebook2.webapp import app
@ -125,6 +126,8 @@ def _do_search( args ):
result = _unload_anno_sr( row, "errata" )
elif row[1] == "user-anno":
result = _unload_anno_sr( row, "user-anno" )
elif row[1] == "asop-entry":
result = _unload_asop_entry_sr( row )
else:
_logger.error( "Unknown searchable row type (rowid=%d): %s", row[0], row[1] )
continue
@ -207,7 +210,14 @@ def _unload_qa_sr( row ):
def _unload_anno_sr( row, atype ):
"""Unload an annotation search result from the database."""
anno = _fts_index[atype][ row[0] ] # nb: our copy of the annotation (must remain unchanged)
result = copy.deepcopy( anno ) # nb: the Q+A entry we will return to the caller (will be changed)
result = copy.deepcopy( anno ) # nb: the annotation we will return to the caller (will be changed)
_get_result_col( result, "content", row[6] )
return result
def _unload_asop_entry_sr( row ):
"""Unload an ASOP entry search result from the database."""
section = _fts_index["asop-entry"][ row[0] ] # nb: our copy of the ASOP section (must remain unchanged)
result = copy.deepcopy( section ) # nb: the ASOP section we will return to the caller (will be changed)
_get_result_col( result, "content", row[6] )
return result
@ -397,12 +407,12 @@ def _adjust_sort_order( results ):
# ---------------------------------------------------------------------
def init_search( content_sets, qa, errata, user_anno, startup_msgs, logger ):
def init_search( content_sets, qa, errata, user_anno, asop, asop_content, startup_msgs, logger ):
"""Initialize the search engine."""
# initialize
global _fts_index
_fts_index = { "index": {}, "q+a": {}, "errata": {}, "user-anno": {} }
_fts_index = { "index": {}, "q+a": {}, "errata": {}, "user-anno": {}, "asop-entry": {} }
# initialize the database
global _sqlite_path
@ -437,6 +447,8 @@ def init_search( content_sets, qa, errata, user_anno, startup_msgs, logger ):
_init_errata( curs, errata, logger )
if user_anno:
_init_user_anno( curs, user_anno, logger )
if asop:
_init_asop( curs, asop, asop_content, logger )
conn.commit()
# load the search config
@ -531,6 +543,46 @@ def _do_init_anno( curs, anno, atype ):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _init_asop( curs, asop, asop_content, logger ):
"""Add the ASOP to the search index."""
logger.info( "- Adding the ASOP." )
sr_type = "asop-entry"
nentries = 0
for chapter in asop.get( "chapters", [] ):
for section in chapter.get( "sections", [] ):
content = asop_content.get( section["section_id"] )
if not content:
continue
entries = _extract_section_entries( content )
# NOTE: The way we manage the FTS index for ASOP entries is a little different to normal,
# since they don't exist as individual entities (this is the only place where they do,
# so that we can return them as individual search results). Each database row points
# to the parent section, and the section has a list of FTS rows for its child entries.
section[ "_fts_rowids" ] = []
for entry in entries:
curs.execute(
"INSERT INTO searchable ( sr_type, content ) VALUES ( ?, ? )", (
sr_type, entry
) )
_fts_index[sr_type][ curs.lastrowid ] = section
section[ "_fts_rowids" ].append( curs.lastrowid )
nentries += 1
logger.info( " - Added %s.", plural(nentries,"entry","entries") )
def _extract_section_entries( content ):
"""Separate out each entry from the section's content."""
entries = []
fragment = lxml.html.fragment_fromstring(
"<div> {} </div>".format( content )
)
for elem in fragment.xpath( ".//div[contains(@class,'entry')]" ):
if "entry" not in elem.attrib["class"].split():
continue
entries.append( lxml.html.tostring( elem ) )
return entries
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def load_search_config( startup_msgs, logger ):
"""Load the search config."""

@ -9,6 +9,7 @@ from asl_rulebook2.webapp import app
from asl_rulebook2.webapp.content import load_content_sets
from asl_rulebook2.webapp.search import init_search
from asl_rulebook2.webapp.rule_info import init_qa, init_errata, init_annotations
from asl_rulebook2.webapp.asop import init_asop
from asl_rulebook2.webapp.utils import parse_int
_logger = logging.getLogger( "startup" )
@ -32,7 +33,11 @@ def init_webapp():
qa = init_qa( _startup_msgs, _logger )
errata = init_errata( _startup_msgs, _logger )
user_anno = init_annotations( _startup_msgs, _logger )
init_search( content_sets, qa, errata, user_anno, _startup_msgs, _logger )
asop, asop_content = init_asop( _startup_msgs, _logger )
init_search(
content_sets, qa, errata, user_anno, asop, asop_content,
_startup_msgs, _logger
)
# ---------------------------------------------------------------------

@ -0,0 +1,178 @@
import { gMainApp, gASOPChapterIndex, gASOPSectionIndex, gEventBus } from "./MainApp.js" ;
import { getASOPChapterIdFromSectionId, wrapMatches, isChildOf } from "./utils.js" ;
let gSectionContentOverrides = {} ;
// --------------------------------------------------------------------
gMainApp.component( "asop", {
data() { return {
isActive: false,
title: null,
preamble: null,
sections: [], isSingleSection: false,
chapterId: null,
} ; },
template: `
<div v-if=isActive :data-chapterid=chapterId id="asop" class="asop" >
<div v-html=title class="title" />
<div v-html=preamble class="preamble" />
<div v-if="sections.length > 0" class="sections" :class="{single: isSingleSection}" ref="sections" >
<div v-for="s in sections" :key=s class="section" v-html=s />
</div>
</div>
`,
created() {
// handle the ASOP being entered/exited
gEventBus.on( "tab-activated", (tabbedPages, tabId) => {
if ( ! isChildOf( tabbedPages.$el, $("#nav")[0], false ) )
return ;
this.isActive = (tabId == "asop") ;
} ) ;
// handle events in the nav pane
gEventBus.on( "asop-chapter-expanded", this.showASOPChapter ) ;
gEventBus.on( "show-asop-section", this.showASOPSection ) ;
gEventBus.on( "show-asop-entry-sr", this.showASOPSectionSearchResult ) ;
// remove search highlights when a new search is done
gEventBus.on( "search", () => {
gSectionContentOverrides = {} ;
} ) ;
},
mounted() {
// start off with the intro
this.showIntro() ;
},
updated() {
// scroll to the top of the sections each time
if ( this.$refs.sections )
this.$refs.sections.scrollTop = 0 ;
},
methods: {
showIntro() {
// show the ASOP intro
this.title = "Advanced Sequence Of Play" ;
this.preamble = null ;
this.sections = [] ;
this.isSingleSection = true ;
this.chapterId = "intro" ;
$.get( gGetASOPIntroUrl, (resp) => { //eslint-disable-line no-undef
this.sections = [ this.fixupContent( resp ) ] ;
} ).fail( (xhr, status, errorMsg) => {
// NOTE: We show the error in the content, not as a notification balloon.
this.sections = [ "Couldn't get the ASOP intro." + " <div class='pre'>" + errorMsg + "</div>" ] ;
} ) ;
} ,
showASOPChapter( chapter, isClick ) {
if ( ! isClick )
return ;
// prepare to show the ASOP chapter (with all sections combined)
this.title = this.makeTitle( chapter, chapter.caption ) ;
this.preamble = this.fixupContent( chapter.preamble ) ;
this.sections = chapter.sections ? Array( chapter.sections.length ) : [] ;
this.isSingleSection = false ;
this.chapterId = chapter.chapter_id ;
if ( this.sections.length == 0 )
return ;
// show each section
let addSectionContent = (sectionNo, content) => {
this.sections[ sectionNo ] =
"<div class='caption'>" + chapter.sections[sectionNo].caption + "</div>"
+ this.fixupContent( content ) ;
} ;
chapter.sections.forEach( (section, sectionNo) => {
// check if there is an override for the next section
let sectionId = chapter.chapter_id + "-" + (1+sectionNo) ;
let contentOverride = gSectionContentOverrides[ sectionId ] ;
if ( contentOverride ) {
// yup - just use that
addSectionContent( sectionNo, contentOverride ) ;
} else {
// nope - download the section from the backend
new Promise( (resolve, reject) => {
let url = gGetASOPSectionUrl.replace( "SECTION_ID", sectionId ) ; //eslint-disable-line no-undef
$.get( url, (resp) => {
addSectionContent( sectionNo, resp ) ;
resolve() ;
} ).fail( (xhr, status, errorMsg) => {
// NOTE: We show the error in the content, not as a notification balloon.
this.sections[ sectionNo ] =
"Couldn't get ASOP section <tt>" + sectionId + "</tt>."
+ " <div class='pre'>" + errorMsg + "</div>" ;
reject() ;
} ) ;
} ) ;
}
} ) ;
},
showASOPSection( chapter, section ) {
this.doShowASOPSection( chapter, section, "" ) ;
// show the specified ASOP section
let sectionId = section.section_id ;
let url = gGetASOPSectionUrl.replace( "SECTION_ID", sectionId ) ; //eslint-disable-line no-undef
$.get( url, (resp) => {
this.doShowASOPSection( chapter, section, resp ) ;
} ).fail( (xhr, status, errorMsg) => {
// NOTE: We show the error in the content, not as a notification balloon.
this.sections = [
"Couldn't get ASOP section <tt>"+sectionId+"</tt>." + " <div class='pre'>" + errorMsg + "</div>"
] ;
} ) ;
},
showASOPSectionSearchResult( sectionId, content ) {
// show the specified ASOP section
let chapterId = getASOPChapterIdFromSectionId( sectionId ) ;
let chapter = gASOPChapterIndex[ chapterId ] ;
if ( ! chapter ) {
console.log( "INTERNAL ERROR: Can't find parent chapter for section ID: " + sectionId ) ;
return ;
}
let section = gASOPSectionIndex[ sectionId ] ;
if ( ! section ) {
console.log( "INTERNAL ERROR: Can't find section ID: " + sectionId ) ;
return ;
}
gSectionContentOverrides[ sectionId ] = content ;
this.doShowASOPSection( chapter, section, content ) ;
},
doShowASOPSection( chapter, section, content ) {
// show the specified ASOP section
this.title = this.makeTitle( chapter, section.caption ) ;
this.preamble = this.fixupContent( chapter.preamble ) ;
let contentOverride = gSectionContentOverrides[ section.section_id ] ;
this.sections = [ this.fixupContent( contentOverride || content ) ] ;
this.isSingleSection = true ;
this.chapterId = chapter.chapter_id ;
},
makeTitle( chapter, caption ) {
// generate a chapter title
if ( chapter.sniper_phase )
caption += "<sup><span title='Sniper Attacks/Checks are possible during this phase.'>&dagger;</span></sup>" ;
return caption ;
},
fixupContent( content ) {
return wrapMatches(
content,
new RegExp( /\[EXC: .*?\]/g ),
"<span class='exc'>", "</span>"
) ;
},
},
} ) ;

@ -4,21 +4,45 @@ import { gMainApp, gEventBus } from "./MainApp.js" ;
gMainApp.component( "accordian", {
props: [ "accordianId" ],
data() { return {
panes: [],
} ; },
template: `
<div class="accordian">
<slot />
</div>`,
mounted() {
// expand the specified pane
gEventBus.on( "expand-pane", (accordianId, paneKey, isClick) => {
if ( this.accordianId != accordianId )
return ;
// update the state for each child pane
this.panes.forEach( (pane) => {
let newIsExpanded = (paneKey != null && pane.paneKey == paneKey) ;
if ( pane.isExpanded && ! newIsExpanded )
pane.$emit( "pane-collapsed", pane.paneKey, isClick ) ;
else if ( ! pane.isExpanded && newIsExpanded )
pane.$emit( "pane-expanded", pane.paneKey, isClick ) ;
pane.isExpanded = newIsExpanded ;
} ) ;
} ) ;
},
} ) ;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
gMainApp.component( "accordian-pane", {
props: [ "paneKey", "title", "entries", "iconUrl", "backgroundUrl" ],
props: [ "paneKey", "title", "entries", "getEntryKey", "iconUrl", "backgroundUrl" ],
data() { return {
isExpanded: false,
cssBackground: this.backgroundUrl ? "url(" + this.backgroundUrl + ")": "#ccc",
cssBackground: this.backgroundUrl ? "url(" + this.backgroundUrl + ")": null,
} ; },
template: `
@ -28,29 +52,16 @@ gMainApp.component( "accordian-pane", {
{{title}}
</div>
<ul v-show=isExpanded :class="{entries: true}" >
<li v-for="e in entries" :key=e class="entry" :class="{disabled: !e.ruleid}" >
<a v-if=e.ruleid @click=onClickEntry(e) > {{e.caption}} </a>
<li v-for="e in entries" :key=e class="entry" :data-key=getEntryKey(e) :class="{disabled: !getEntryKey(e)}" >
<a v-if=getEntryKey(e) @click=onClickEntry(e) > {{e.caption}} </a>
<span v-else> {{e.caption}} </span>
</li>
</ul>
</div>`,
created() {
// handle panes being expanded
gEventBus.on( "expand-pane", (entry) => {
// check if we are in the same accordian as the pane being toggled
if ( entry.$parent != this.$parent )
return ;
// yup - check if we are the pane being toggled
if ( entry == this ) {
// yup - update our state
this.isExpanded = ! this.isExpanded ;
} else {
// nope - always close up (only one pane can be open at a time)
this.isExpanded = false ;
}
} ) ;
// notify the parent
this.$parent.panes.push( this ) ;
},
methods: {
@ -59,12 +70,11 @@ gMainApp.component( "accordian-pane", {
this.$emit( "entry-clicked", this.paneKey, entry ) ;
},
onToggleExpand() {
// notify the parent that a pane was expanded
if ( ! this.isExpanded )
this.$emit( "pane-expanded" ) ;
// NOTE: Every accordian pane will receive this event, but each one
// will figure out if it applies to them.
gEventBus.emit( "expand-pane", this ) ;
// notify the parent
gEventBus.emit( "expand-pane", this.$parent.accordianId,
this.isExpanded ? null : this.paneKey,
true
) ;
},
},

@ -8,11 +8,14 @@ gMainApp.component( "content-pane", {
props: [ "contentDocs" ],
template: `
<tabbed-pages ref="tabbedPages">
<tabbed-page v-for="cdoc in contentDocs" :tabId=cdoc.cdoc_id :caption=cdoc.title :key=cdoc.cdoc_id >
<content-doc :cdoc=cdoc />
</tabbed-page>
</tabbed-pages>`,
<div>
<tabbed-pages ref="tabbedPages">
<tabbed-page v-for="cdoc in contentDocs" :tabId=cdoc.cdoc_id :caption=cdoc.title :key=cdoc.cdoc_id >
<content-doc :cdoc=cdoc />
</tabbed-page>
</tabbed-pages>
<asop />
</div>`,
created() {

@ -19,6 +19,8 @@ export let gContentDocs = null ;
export let gTargetIndex = null ;
export let gFootnoteIndex = null ;
export let gChapterResources = null ;
export let gASOPChapterIndex = null ;
export let gASOPSectionIndex = null ;
// --------------------------------------------------------------------
@ -26,11 +28,12 @@ gMainApp.component( "main-app", {
data() { return {
contentDocs: [],
asop: {},
isLoaded: false,
} ; },
template: `
<nav-pane id="nav" ref="navPane" />
<nav-pane id="nav" :asop=asop ref="navPane" />
<content-pane id="content" :contentDocs=contentDocs />
<div v-if=isLoaded id="_mainapp-loaded_" />
`,
@ -49,6 +52,7 @@ gMainApp.component( "main-app", {
this.getAppConfig(),
this.getContentDocs( this ),
this.getFootnoteIndex(),
this.getASOP(),
] ).then( () => {
this.isLoaded = true ;
gEventBus.emit( "app-loaded" ) ;
@ -120,6 +124,33 @@ gMainApp.component( "main-app", {
} ) ;
},
getASOP() {
return new Promise( (resolve, reject) => {
// get the ASOP
$.getJSON( gGetASOPUrl, (resp) => { //eslint-disable-line no-undef
this.asop = resp ;
// build an index of the ASOP chapters and sections
gASOPChapterIndex = {} ;
gASOPSectionIndex = {} ;
if ( resp.chapters ) {
resp.chapters.forEach( (chapter) => {
gASOPChapterIndex[ chapter.chapter_id ] = chapter ;
if ( chapter.sections ) {
chapter.sections.forEach( (section) => {
gASOPSectionIndex[ section.section_id ] = section ;
} ) ;
}
} ) ;
}
resolve() ;
} ).fail( (xhr, status, errorMsg) => {
let msg = "Couldn't get the ASOP." ;
showErrorMsg( msg + " <div class='pre'>" + errorMsg + "</div>" ) ;
reject( msg )
} ) ;
} ) ;
},
installContentDocs( contentDocs ) {
// install the content docs
gContentDocs = contentDocs ;

@ -1,10 +1,11 @@
import { gMainApp, gAppConfig, gContentDocs, gEventBus } from "./MainApp.js" ;
import { showWarningMsg } from "./utils.js" ;
import { getASOPChapterIdFromSectionId, showWarningMsg } from "./utils.js" ;
// --------------------------------------------------------------------
gMainApp.component( "nav-pane", {
props: [ "asop" ],
data() { return {
ruleInfo: [],
} ; },
@ -18,6 +19,11 @@ gMainApp.component( "nav-pane", {
<tabbed-page tabId="chapters" caption="Chapters" >
<nav-pane-chapters />
</tabbed-page>
<tabbed-page v-if="asop.chapters && asop.chapters.length > 0"
tabId="asop" caption="ASOP" ref="asop"
>
<nav-pane-asop :asop=asop />
</tabbed-page>
</tabbed-pages>
<rule-info :ruleInfo=ruleInfo @close=closeRuleInfo />
</div>`,
@ -104,12 +110,13 @@ gMainApp.component( "nav-pane-chapters", {
} ; },
template: `
<accordian>
<accordian accordianId="chapters" >
<accordian-pane v-if="chapters.length > 0" v-for="c in chapters"
:key=c :paneKey=c[0] :entries=c[1].sections
:key=c :paneKey=c
:entries=c[1].sections :getEntryKey=getEntryKey
:title=c[1].title :iconUrl=c[1].icon :backgroundUrl=c[1].background
@pane-expanded=onChapterPaneExpanded(c[0],c[1])
@entry-clicked=onChapterSectionClicked
@pane-expanded=onChapterPaneExpanded
@entry-clicked=onChapterEntryClicked
/>
<div v-else class="no-chapters"> No chapters. </div>
</accordian>
@ -130,16 +137,72 @@ gMainApp.component( "nav-pane-chapters", {
methods: {
onChapterPaneExpanded( cdocId, chapter ) {
onChapterPaneExpanded( chapter, isClick ) { //eslint-disable-line no-unused-vars
// show the first page of the specified chapter
gEventBus.emit( "show-page", cdocId, chapter.page_no ) ;
gEventBus.emit( "show-page", chapter[0], chapter[1].page_no ) ;
},
onChapterSectionClicked( cdocId, entry ) {
// show the chapter section's target
gEventBus.emit( "show-target", cdocId, entry.ruleid ) ;
onChapterEntryClicked( paneKey, entry ) {
// show the chapter entry's target
gEventBus.emit( "show-target", paneKey[0], entry.ruleid ) ;
},
getEntryKey( entry ) { return entry.ruleid ; },
},
} ) ;
// --------------------------------------------------------------------
gMainApp.component( "nav-pane-asop", {
props: [ "asop" ],
data() { return {
footer: null,
} ; },
template: `
<accordian accordianId="asop" >
<accordian-pane v-if="asop.chapters.length > 0" v-for="c in asop.chapters"
:key=c :paneKey=c :data-chapterid=c.chapter_id
:entries=c.sections :getEntryKey=getEntryKey
:title=c.caption
@pane-expanded=onPaneExpanded @entry-clicked=onEntryClicked
/>
</accordian>
<div v-show=footer v-html=footer id="asop-footer" />
`,
created() {
// get the ASOP footer
$.get( gGetASOPFooterUrl, (resp) => { //eslint-disable-line no-undef
this.footer = resp ;
} ).fail( (xhr, status, errorMsg) => {
console.log( "Couldn't get the ASOP footer: " + errorMsg ) ;
} ) ;
// open the appropriate chapter pane when the user clicks on an ASOP section search result
gEventBus.on( "show-asop-entry-sr", (sectionId, content) => { //eslint-disable-line no-unused-vars
let chapterId = getASOPChapterIdFromSectionId( sectionId ) ;
this.asop.chapters.forEach( (chapter) => {
if ( chapter.chapter_id == chapterId )
gEventBus.emit( "expand-pane", "asop", chapter ) ;
} ) ;
} ) ;
},
methods: {
// NOTE: We forward events to the ASOP popup for processing.
onPaneExpanded( chapter, isClick ) { gEventBus.emit( "asop-chapter-expanded", chapter, isClick ) ; },
onEntryClicked( chapter, section ) { gEventBus.emit( "show-asop-section", chapter, section ) ; },
getEntryKey( section ) { return section.section_id ; },
},
} ) ;

@ -68,6 +68,7 @@ gMainApp.component( "search-results", {
<qa-entry v-else-if="sr.sr_type == 'q+a'" :qaEntry=sr class="sr rule-info" />
<annotation v-else-if="sr.sr_type == 'errata'" :anno=sr class="sr rule-info" />
<annotation v-else-if="sr.sr_type == 'user-anno'" :anno=sr class="sr rule-info" />
<asop-entry-sr v-else-if="sr.sr_type == 'asop-entry'" :sr=sr class="sr" />
<div v-else> ???:{{sr.sr_type}} </div>
</div>
</div>`,
@ -148,7 +149,9 @@ gMainApp.component( "search-results", {
} ) ;
}
} ) ;
} else if( sr.sr_type == "errata" || sr.sr_type == "user-anno" ) {
} else if ( sr.sr_type == "errata" || sr.sr_type == "user-anno" ) {
sr.content = fixupSearchHilites( sr.content ) ;
} else if ( sr.sr_type == "asop-entry" ) {
sr.content = fixupSearchHilites( sr.content ) ;
} else {
console.log( "INTERNAL ERROR: Unknown search result type:", sr.sr_type ) ;

@ -142,6 +142,27 @@ gMainApp.component( "index-sr", {
// --------------------------------------------------------------------
gMainApp.component( "asop-entry-sr", {
props: [ "sr" ],
template: `
<div class="sr asop-entry-sr asop" >
<div v-html="sr.caption+' (ASOP)'" @click=onClickCaption class="caption" title="Go to the ASOP" />
<div class="content" v-html=sr.content />
</div>`,
methods: {
onClickCaption() {
gEventBus.emit( "activate-tab", "#nav", "asop" ) ;
gEventBus.emit( "show-asop-entry-sr", this.sr.section_id, this.sr.content ) ;
},
},
} ) ;
// --------------------------------------------------------------------
gMainApp.component( "ruleid", {
props: [ "csetId", "ruleId" ],

@ -1,4 +1,5 @@
import { gMainApp, gEventBus } from "./MainApp.js" ;
import { isChildOf } from "./utils.js" ;
// --------------------------------------------------------------------
@ -31,7 +32,7 @@ gMainApp.component( "tabbed-pages", {
sel = sel.substring( 1 ) ;
else
console.log( "INTERNAL ERROR: Tabs should be activated via a selector ID." ) ;
if ( this.$el.getAttribute("id") != sel && ! $.contains( $sel[0], this.$el ) )
if ( ! isChildOf( this.$el, $sel[0], false ) )
return ;
// yup - activate the specified tab
this.activateTab( tabId ) ;
@ -57,6 +58,7 @@ gMainApp.component( "tabbed-pages", {
$( this.$el ).find( ".tabbed-page" ).each( function() {
$(this).css( "display", ($(this).data("tabid") == tabId) ? "block" : "none" ) ;
} ) ;
gEventBus.emit( "tab-activated", this, tabId ) ;
},
},

@ -0,0 +1,39 @@
/* NOTE: This file defines general CSS for the ASOP. Any user-defined settings that are specific
* to the user's ASOP files can be defined in a file called asop.css, in the ASOP directory.
*
* "#asop" refers to the ASOP popup window that overlays the content pane, but anything
* that styles the ASOP content itself should be referenced via ".asop" (since ASOP content
* can also appear as a search result in the left sidebar).
* */
#asop {
position: absolute ; top: 5px ; left: 5px ; bottom: 5px ; right: 5px ; z-index: 20 ;
display: flex ; flex-direction: column ;
border: 1px solid #444 ; border-radius: 5px ; background: white ;
padding: 10px ;
}
#nav .tabbed-page[data-tabid="asop"] .wrapper { display: flex ; flex-direction: column ; }
#nav .tabbed-page[data-tabid="asop"] .accordian { flex-grow: 1 ; overflow-y: auto ; }
#nav .tabbed-page[data-tabid="asop"] .accordian-pane .title { background: #f6edda ; border: 1px solid #e5cea0 ; font-size: 110% ; }
#nav .tabbed-page[data-tabid="asop"] .accordian-pane[data-chapterid="pre-game"] .title { background: #fff6e2 ; }
#nav .tabbed-page[data-tabid="asop"] .accordian .entry { margin: 6px 0 ; }
#asop-footer { border-top: 1px solid #ccc ; margin-top: 5px ; padding: 5px ; }
#asop>.title { background: #f6edda ; border: 1px solid #e5cea0 }
#asop[data-chapterid="pre-game"]>.title { background: #fff6e2 ; }
.asop .pre { margin-left: 30px ; font-family: monospace ; }
.asop .exc { font-style: italic ; color: #666 ; }
.asop .hilite { padding: 0 2px ; background: #ffa ; }
.asop .title { border: 1px solid #666 ; border-radius: 2px ; background: #eee ; font-size: 125% ; font-weight: bold ; padding: 2px 5px ; }
.asop .preamble { margin-bottom: 5px ; padding: 2px 5px ; font-size: 90% ; font-style: italic ; }
.asop .preamble i, #asop .preamble em { font-style: normal ; }
.asop .sections { flex-grow: 1 ; overflow-y: auto ; }
.asop .section { margin-top: 10px ; border: 1px solid #444 ; border-radius: 5px; padding: 10px ; }
.asop .section:first-of-type { margin-top: 0 ; }
.asop .sections.single .section { border: none ; padding: 0 5px ; }
.asop .section .caption { font-weight: bold ; }

@ -11,5 +11,6 @@
.accordian-pane .title .icon { height: 20px ; margin-right: 10px ; }
.accordian-pane .entries { margin: 0 0 5px 15px ; list-style-type: none ; }
.accordian-pane .entries .entry { margin: 2px 0 ; }
.accordian-pane .entries .entry a { cursor: pointer ; }
.accordian-pane .entries .entry.disabled { color: #666 ; }

@ -22,7 +22,6 @@
.rule-info p { margin-top: 5px ; }
.rule-info br { margin-top: 3px ; }
.rule-info ul, .qa ol { margin-left: 20px ; }
.rule-info .quote { font-style: italic ; color: #406040 ; }
.rule-info .exc { font-style: italic ; color: #666 ; }

@ -9,9 +9,12 @@
#search-results .index-sr .see-also { color: #444 ; font-style: italic ; cursor: pointer ; }
#search-results .index-sr .see-also a { border-bottom: 1px dotted #888 ; }
#search-results .index-sr img.toggle-rulerefs { float: right ; margin: 0 0 0.25em 0.25em ; height: 1.25em ; cursor: pointer ; }
#search-results .index-sr ul.rulerefs { margin-left: 1.2em ; }
#search-results .index-sr ul.rulerefs .caption { padding-right: 0.5em ; }
#search-results .index-sr ul.rulerefs .ruleid { font-size: 80% ; }
#search-results .index-sr .ruleid { margin-right: 0.25em ; font-style: italic ; color: #444 ; }
#search-results .index-sr .ruleid.unknown { color: #888 ; }
#search-results .index-sr .ruleid a { cursor: pointer ; }
/* nb: the real ASOP background color is #b72754, but it's quite dark */
#search-results .asop-entry-sr .caption { padding: 3px 6px ; font-weight: bold ; border-radius: 3px ; background: #f6edda ; cursor: pointer ; }
#search-results .asop-entry-sr .content { padding: 5px ; }

@ -1,12 +1,13 @@
*/* global reset */
* { margin: 0 ; padding: 0 }
html { height: 100% ; }
body { height: 100% ; overflow: hidden ; }
* { margin: 0 ; padding: 0 }
html { height: 100% ; }
body { height: 100% ; overflow: hidden ; }
/* general styling */
body { font-family: Arial, Helvetica, sans-serif ; font-size: 16px ; }
input[type="text"] { height: 22px ; padding: 0 5px ; }
button { height: 24px ; padding: 0 5px !important ; }
/* general styling */
body { font-family: Arial, Helvetica, sans-serif ; font-size: 16px ; }
input[type="text"] { height: 22px ; padding: 0 5px ; }
button { height: 24px ; padding: 0 5px !important ; }
ul, ol { margin-left: 15px ; }
/* notification balloons */
.growl { cursor: pointer ; }

@ -44,6 +44,15 @@ export function isRuleid( val )
return val.match( /^[A-Z](\.|CG)?\d/ ) ;
}
export function getASOPChapterIdFromSectionId( sectionId )
{
// NOTE: Section ID's have the form "XXX-#", where XXX is the chapter ID and # is the sequence number.
let pos = sectionId.lastIndexOf( "-" ) ;
if ( pos < 0 )
return null ;
return sectionId.substring( 0, pos ) ;
}
// --------------------------------------------------------------------
const BEGIN_HIGHLIGHT = "!@:" ;
@ -136,6 +145,33 @@ export function makeImagesZoomable( $elem )
// --------------------------------------------------------------------
export function wrapMatches( val, searchFor, delim1, delim2 )
{
// search for a regex and wrap all matches with the specified delimiters
if ( val == null || val == undefined )
return null ;
let buf = [] ;
let pos = 0 ;
for ( let match of val.matchAll( searchFor ) ) {
buf.push(
val.substring( pos, match.index ),
delim1, match[0], delim2
) ;
pos = match.index + match[0].length ;
}
buf.push( val.substring( pos ) ) ;
return buf.join("") ;
}
export function isChildOf( elem, elemParent, strict )
{
// check if an element is a child of another element
if ( $.contains( elemParent, elem ) )
return true ;
if ( ! strict && elem.isSameNode( elemParent ) )
return true ;
}
export function getCssSize( elem, attr )
{
// return the element's size

@ -17,8 +17,12 @@
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/SearchResult.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/RuleInfo.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/ContentPane.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/ASOP.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/TabbedPages.css' ) }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for( 'static', filename='css/Accordian.css' ) }}" />
{%if ASOP_CSS_URL%}
<link rel="stylesheet" type="text/css" href="{{ASOP_CSS_URL}}" />
{%endif%}
</head>
<body>
@ -51,6 +55,10 @@
gImagesBaseUrl = "{{ url_for( 'static', filename='images/' ) }}" ;
gGetAppConfigUrl = "{{ url_for( 'get_app_config' ) }}" ;
gGetContentDocsUrl = "{{ url_for( 'get_content_docs' ) }}" ;
gGetASOPUrl = "{{ url_for( 'get_asop' ) }}" ;
gGetASOPIntroUrl = "{{ url_for( 'get_asop_intro' ) }}" ;
gGetASOPFooterUrl = "{{ url_for( 'get_asop_footer' ) }}" ;
gGetASOPSectionUrl = "{{ url_for( 'get_asop_section', section_id='SECTION_ID' ) }}" ;
gGetStartupMsgsUrl = "{{ url_for( 'get_startup_msgs' ) }}" ;
gSearchUrl = "{{ url_for( 'search' ) }}" ;
gGetRuleInfoUrl = "{{ url_for( 'get_rule_info', ruleid='RULEID' ) }}" ;
@ -64,6 +72,7 @@ gGetFootnotesUrl = "{{ url_for( 'get_footnotes' ) }}" ;
<script type="module" src="{{ url_for( 'static', filename='SearchResult.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='RuleInfo.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='ContentPane.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='ASOP.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='TabbedPages.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='Accordian.js' ) }}"></script>
<script type="module" src="{{ url_for( 'static', filename='utils.js' ) }}"></script>

@ -0,0 +1,9 @@
<div class="entry"> <div class=A> 7.11A </div> <ul>
<li> May Transfer SW/Guns/Prisoners (A4.431; A20.5) {Ski-use dr; E4.21}.
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A> 7.13A </div> <ul>
<li> Boat/Non-Aground LC in Heavy Surf makes any required (un)Beaching DR (G13.442/G13.4423).
</ul> </div>

@ -0,0 +1,12 @@
<div class="entry"> <div class=A> 7.21A </div> <ul>
<li> Good Order Infantry, not pinned or TI, may advance (A4.7): <ul>
<li> PAATC; A11.6
<li> vs. Difficult Terrain = CX/Panji MC; A4.72, G9.41
</ul>
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A> 7.26A </div> <ul>
<li> All &#x215D;" Parachutes on-board are removed, and replaced by their contents (E9.6).
</ul> </div>

@ -0,0 +1,17 @@
<div class="entry"> <div class=B> 5.11B </div> <ul>
<li> During Mild Breeze, place Drifting (i.e. gray) Dispersed SMOKE downwind of: <ul>
<li> each Blaze
<li> each white SMOKE counter that has none (A24.61)
</ul>
[EXC: NA in cave; G11.851]
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A> 5.13A </div> <ul>
<li> During Gusts (B25.651): <ul>
<li> Remove Dispersed SMOKE.
<li> Flip remaining SMOKE counters to Dispersed side.
</ul>
[EXC: NA in Cave; G11.8]
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=A> 5.21A </div> <ul>
<li> Place all Glider contents <span class=exc>[EXC: vehicle/Gun and its PRC/Crew]</span> on-board (E8.4).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A> 5.23A </div> <ul>
<li> Each berserk unit that eliminated all Known enemy units (at least one) in its Location with halved TPBF, returns to Good Order (A15.46).
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=B> 5.31B </div> <ul>
<li> Resolve Blaze Spread (B25.6; B25.651) every Player Turn <i>after</i> initial appearance.
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=B> 5.33B </div> <ul>
<li> If night scenario, also remove all First Fire, Final Fire, and Gunflash counters (E1.8).
</ul> </div>

@ -0,0 +1,24 @@
.asop .A {
padding-left: 1.5em ; background: url(images/attacker.png) no-repeat ; background-size: 1.1em ;
margin-top: 0.5em ; font-weight: bold ;
}
.asop .D {
padding-left: 1.5em ; background: url(images/defender.png) no-repeat ; background-size: 1.1em ;
margin-top: 0.5em ; font-weight: bold ;
}
.asop .B {
padding-left: 1.5em ; background: url(images/both-players.png) no-repeat ; background-size: 1.1em ;
margin-top: 0.5em ; font-weight: bold ;
}
.asop img.icon { height: 1em ; margin-bottom: -2px ; }
.asop .content-removed { margin: 5px 0 ; font-style: italic ; color: #aaa ; }
.asop p { margin-top: 0.5em ; }
.asop p:first-of-type { margin-top: 0 ; }
.asop li { margin-top: 0.25em ; }
.asop li li { margin-top: 0.1em ; }
.asop li li li { margin-top: 0.1em ; }
#asop-footer { font-size: 80% ; font-style: italic ; }
#asop-footer img { height: 1.2em ; margin-right: 0.25em ; }

@ -0,0 +1 @@
Perform all Steps listed under <i>"... LOCATION'S CCPh" </i>in any one CC/Melee Location first, then in the next such Location, etc.

@ -0,0 +1,9 @@
<div class="entry"> <div class=B> 8.11B </div> <ul>
<li> Place on-board, beneath a "?", all hidden items, then reveal Strength Factors of all concealed units (eliminating Dummies) (A11.19).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=B> 8.16B </div> <ul>
<li> Declare each SMC's solo status, or pair it with another SMC or MMC (A11.14); ATTACKER first (A11.12).
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=B> 8.21B </div> <ul>
<li> Declare first/next sequential CC attack (A11.3-.34) or, ATTACKER first (A11.12; G13.495), all simultaneous CC attacks, if no sequential CC exists.
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=B> 8.25B </div> <ul>
<li> May Interrogate new Prisoners (E2.1; G1.621; G18.71).
</ul> </div>

@ -0,0 +1,5 @@
<div class="entry"> <div class=B> 8.31B </div> <ul>
<li> Automatic capture of unescorted, abandoned vehicles (A21.2).
</ul> </div>
{{CONTENT_REMOVED|safe}}

@ -0,0 +1,13 @@
<div class="entry"> <div class=B> 8.41B </div> <ul>
<li> Declare and resolve (sequentially; ATTACKER first) all Aerial Combat (E7.22-.226).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=B> 8.45B </div> <ul>
<li> If night, remove all: <ul>
<li> Starshells (E1.923)
<li> IR (E1.933)
<li> Acquisition not Illuminated by Blaze/Flame (E1.74)
</ul>
</ul> </div>

@ -0,0 +1,11 @@
<div class="entry"> <div class=D> 4.11D </div> <ul>
<li> May fire ordnance Dispersed SMOKE (C8.5)/MTR IR (E1.91; E1.93-.932). <ul>
<li> Resolve ensuing WP NMC (A24.31).
</ul>
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=D> 4.14D </div> <ul>
<li> Check for Column Disbandment (E11.533) and Reverse Slopes (G14.66-.661).
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=D> 4.21D </div> <ul>
<li> May designate Spotters for MTR's that had no original Spotter (C9.3).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=D> 4.22D </div> <ul>
<li> May (un)limber Guns (C10.21; it and crew become TI if unlimbering).
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=D> 4.31D </div> <ul>
<li> May change CA of Guns presently able to fire without using Intensive Fire (C3.22).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=D> 4.32D </div> <ul>
<li> In daytime scenario, remove all First and Final Fire counters (A3.4; E1.8).
</ul> </div>

@ -0,0 +1,13 @@
{# The contents of this file will appear in the nav panel, underneath the list of ASOP chapters.
You can just delete it, if you don't want it.
#}
<div style="display:flex;align-items:center;">
<img src="{{ASOP_BASE_URL}}/images/attacker.png"> ATTACKER &nbsp;
<img src="{{ASOP_BASE_URL}}/images/defender.png"> DEFENDER &nbsp;
<img src="{{ASOP_BASE_URL}}/images/both-players.png"> Both &nbsp;
</div>
<div style="margin-top:0.5em;">
&dagger; Sniper Attacks/Checks are possible during this phase (A14.1; A14.4; E1.72; E1.76; G12.603; G14.261; <i>RB</i> SSR CG8, <i>ABtF</i> SSR CG8, <i>BRT</i> SSR CG8, <i>KGP</i> SSR CG16, and <i>PB</i> SSR CG13).
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@ -0,0 +1,78 @@
{
"chapters": [
{ "caption": "Pre-Game Sequence", "chapter_id": "pre-game",
"sections": [
{ "caption": "First steps" },
{ "caption": "First side set up" },
{ "caption": "Interlude" },
{ "caption": "Second side set up" },
{ "caption": "Final steps" }
] },
{ "caption": "1. Rally Phase", "chapter_id": "rally",
"sections": [
{ "caption": "1.1: START of RPh" },
{ "caption": "1.2: DURING RPh" },
{ "caption": "1.3: END of RPh" }
] },
{ "caption": "2. Prep Fire Phase", "chapter_id": "prep-fire",
"sniper_phase": true,
"sections": [
{ "caption": "2.1: START of PFPh" },
{ "caption": "2.2: DURING PFPh" },
{ "caption": "2.3: END of PFPh" }
] },
{ "caption": "3. Movement Phase", "chapter_id": "movement",
"sniper_phase": true,
"sections": [
{ "caption": "3.1: START of the MPh" },
{ "caption": "3.2: START of ITS MPh" },
{ "caption": "3.3: DURING ITS MPh" },
{ "caption": "3.4: END of ITS MPh" },
{ "caption": "3.5: END of the MPh" }
] },
{ "caption": "4. Defensive Fire Phase", "chapter_id": "defensive-fire",
"sniper_phase": true,
"sections": [
{ "caption": "4.1: START of DFPh" },
{ "caption": "4.2: DURING DFPh" },
{ "caption": "4.3: END of DFPh" }
] },
{ "caption": "5. Advancing Fire Phase", "chapter_id": "advancing-fire",
"sniper_phase": true,
"sections": [
{ "caption": "5.1: START of AFPh" },
{ "caption": "5.2: DURING AFPh" },
{ "caption": "5.3: END of AFPh" }
] },
{ "caption": "6. Rout Phase", "chapter_id": "rout",
"sections": [
{ "caption": "6.1: START of RtPh" },
{ "caption": "6.2: DURING RtPh" },
{ "caption": "6.3: END of RtPh" }
] },
{ "caption": "7. Advance Phase", "chapter_id": "advance",
"sections": [
{ "caption": "7.1: START of AFPh" },
{ "caption": "7.2: DURING AFPh" }
] },
{ "caption": "8. Close Combat Phase", "chapter_id": "close-combat",
"sections": [
{ "caption": "8.1: START of LOCATION's CCPh" },
{ "caption": "8.2: DURING LOCATION's CCPh" },
{ "caption": "8.3: END of LOCATION's CCPh" },
{ "caption": "8.4: END of CCPh" }
] },
{ "chapter_id": "empty",
"_comment_": "This is used for testing porpoises only."
}
],
"template_args": {
"CONTENT_REMOVED": "<div class='content-removed'> ...content removed... </div>"
}
}

@ -0,0 +1,10 @@
<p> As listed in the Advanced Sequence of Play (ASOP), each phase is usually broken down into three main parts: the START, DURING, END, and several discrete Steps. In each Step Number (e.g. "1.11A"), the players involved are specified as:
<ul>
<li class=A> ATTACKER
<li class=D> DEFENDER
<li class=B> Both
</ul>
<p> {{CONTENT_REMOVED|safe}}
<p> Certain mutually exclusive actions may be listed in the same Step, despite the fact that they cannot be conducted by the same unit, and many restrictions normally applicable to the listed actions are left unmentioned. In both cases, the normal rules pertaining to such actions still apply.</p>

@ -0,0 +1,5 @@
<p>The MPh Sequence of Play is expressed separately in terms of <i>THE</i> MPh, and of each moving unit/stack's MPh ...
{{CONTENT_REMOVED|safe}}
<p> ... must be completed before any Glider/Parachute may start <i>ITS</i> MPh.

@ -0,0 +1,9 @@
<div class="entry"> <div class=A> 3.11A </div> <ul>
<li> May designate new mortar Spotter, for one eliminated or not in Good Order (C9.3).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A> 3.13A </div> <ul>
<li> Place all Gliders, blue side up (i.e, in Aerial Locations), on-board in their ILH (E8.2).
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=A> 3.21A </div> <ul>
<li> Prepare to move any currently berserk unit/stack required to charge (A15.43); then go to Step 3.31A <span class="exc">[EXC: if no such berserk unit can charge, go to Step 3.22A]</span>.
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A> 3.23A </div> <ul>
<li> Prepare to conduct Glider/Parachute movement; go to Step 3.37D <span class="exc">[EXC: if no Aerial Glider exists, go to Step 3.34A]</span>.
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=A> 3.31A </div> <ul>
<li> Berserk unit charges, if so required (A15.43-.431; A15.45; G13.491).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=D> 3.38D </div> <ul>
<li> Then go to Step 3.43A.
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=A>3.41A</div> <ul>
<li> Non-Bypassing Good Order Infantry/Cavalry may Search (A12.152; E1.95/E1.953; G1.63) {Casualties; A12.154}; becomes TI - Defensive First/Subsequent First/FPF allowed.
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A>3.43A</div> <ul>
<li> Then go to Step 3.5.
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <div class=A> 3.51A </div> <ul>
<li> Each vehicle unable to leave, and each Glider/Parachute that landed in, terrain Blaze Location is eliminated (B25.4; E8.232; E9.42).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=B> 3.53B </div> <ul>
<li> Remove all Residual FP (A8.2; A9.223), and &#x00BD;" SMOKE (A24.11 <span class=exc>[EXC: G11.85]</span>) counters.
</ul> </div>

@ -0,0 +1,3 @@
<p> Follow in the order given. Not all will apply to every scenario. Items pertinent only to a DYO scenario are marked with a <img src="{{ASOP_BASE_URL}}/images/dyo.png" class="icon">.
<p> Should the order of actions given in the body of the rules conflict with this Sequence, the latter takes precedence, <em>except</em> in the case of a CG Refit Phase.

@ -0,0 +1,9 @@
<div class="entry"> <ul>
<li> Agree upon which (if any) optional/house rules will be in effect (A16, B10.211, C13.311, E1-E2, E4-E12, footnote A18/C5/C9, Incremental IFT (A7.37), etc.).
<li> {{CONTENT_REMOVED|safe}}
<li> <img src="{{ASOP_BASE_URL}}/images/dyo.png" class="icon"> Determine EC (B25.5, F11.4, G16.3, or O11.618; see also E3.3, E3.4, E3.6, E3.713, E3.72, E3.73, E3.74, and/or F11.6111, R9.62163).
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <ul>
<li> First side (or the side "defending the beach"; G13.95) commences setup.
<li> {{CONTENT_REMOVED|safe}}
<li> First side (or the side "defending the beach"; G13.95) completes setup.
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <ul>
<li> Scenario Attacker makes one Recon dr, if allowed (E1.23).
<li> {{CONTENT_REMOVED|safe}}
<li> <img src="{{ASOP_BASE_URL}}/images/dyo.png" class="icon"> Determine Surf (G13.98; see also G13.448), if applicable.
</ul> </div>

@ -0,0 +1,7 @@
<div class="entry"> <ul>
<li>Second side commences setup (also repeat all Steps above marked with <img src="{{ASOP_BASE_URL}}/images/both-players.png" class="icon">).
<li>Second side completes setup.
</ul> </div>

@ -0,0 +1,9 @@
<div class="entry"> <ul>
<li> Record all allowed NOBA Ocean hexes (G14.62).
<li> {{CONTENT_REMOVED|safe}}
<li> Scenario Attacker determines Creeping-Barrage timing (E12.72), if applicable, then conducts (E12.72-.74) all "pre-Game Turns" if/as required.
</ul> </div>

@ -0,0 +1,18 @@
<!-- NOTE: ASOP entries should be in separate <div> blocks, with class "entry".
The Prep Fire data files use different ways of setting these blocks up, for testing porpoises only.
If you are using these files as a basis for setting up a real ASOP, you can replace them with
the more normal:
<div class="entry">
...
</div>
-->
<div foo="bar" class="entry" this="that" > <div class=A> 2.11A </div> <ul>
<li> Remove his Dispersed SMOKE (checking for any Napalm terrain-Blaze/weapon destruction; G17.41); then flip his SMOKE counters to their Dispersed side (A24.4).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class='entry'> <div class=A> 2.15A </div> <ul>
<li> Check for Column Disbandment (E11.533) and Reverse Slopes (G14.66-.661).
</ul> </div>

@ -0,0 +1,21 @@
<!-- NOTE: ASOP entries should be in separate <div> blocks, with class "entry".
The Prep Fire data files use different ways of setting these blocks up, for testing porpoises only.
If you are using these files as a basis for setting up a real ASOP, you can replace them with
the more normal:
<div class="entry">
...
</div>
-->
<div class = "foo entry bar"> <div class=A> 2.21A </div> <ul>
<li> Infantry MMC may become TI and: <ul>
<li> Mop Up (A12.153) {Casualties; A12.154}, or
<li> attempt to entrench (A25.21; B27.11; F.1B; G3.5; G13.3; G13.82), placing Labor counter if unsuccessful.
</ul>
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class = ' entry '> <div class=A> 2.23A </div> <ul>
<li> May (un)limber Guns (C10.21; it and crew become TI, if unlimbering).
</ul> </div>

@ -0,0 +1,8 @@
<div class="entry"> <div class=A>2.31A</div> <ul>
<li> May change CA of Guns presently able to fire without using Intensive Fire (C3.22).
<li> May designate/cancel AA mode of weapons that can/does thusly change CA (E7.5).
</ul> </div>
<div class="_entry_" style="font-size:80%;font-style:italic;color:#aaa;">
This text is here for testing porpoises only, and should not be detected by the search engine (because it's not part of an "entry" div).
</div>

@ -0,0 +1 @@
Only one action (attempt) allowed per unit per RPh [EXC: repairing &gt; one SW/Gun (A9.72); leader rallying &gt; one unit (A10.7); Recovery (A4.44) is not an action by a broken unit].

@ -0,0 +1,16 @@
<div class="entry"> <div class=A> 1.11A </div> <ul>
<li> Roll for any provisional (SSR) reinforcements (including Air Support; E7.2).
<li> Set up, off-board, all forces due to enter this Player Turn (A2.51-.52). <ul>
<li> DD tanks; D16.8
<li> Cloaking; E1.41
<li> Gliders; E8.1
<li> Parachutes; E9.1-.11
<li> LC; G1.664/G14.23
</ul>
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=B> 1.14B </div> <ul>
<li> May attempt to Recover SW/Guns in same Location (A4.44; D6.31; G.5) {Ski-use dr; E4.21}.
</ul> </div>

@ -0,0 +1,12 @@
<div class="entry"> <div class=A> 1.21A </div> <ul>
<li> May (attempt to) Deploy Good Order squads, if Good Order leader present, and/or Unarmed/Guards/Finns/Carrier HS/USMC 7-6-8's without leader (A1.31; G17.11).
<li> Infantry MMC may attempt to Scrounge abandoned vehicles or non-burning wrecks (D10.5). <ul>
<li> Place Scrounged and TI markers.
</ul>
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=A>1.24A</div> <ul>
<li> Determine final Drop Point for each Para Wing, then place all Sticks (i.e. Parachutes; E9.12) on-board, in Aerial Locations.
</ul> </div>

@ -0,0 +1,10 @@
<div class="entry"> <div class=B> 1.31B </div> <ul>
<li> Roll for Shocked/UK AFV recuperation (C7.42). <ul>
<li> Remove or flip marker/AFV, as appropriate.
</ul>
<li> May/must remove DM markers from eligible broken units (A10.62).
</ul> </div>
<div class="entry"> <div class=B> 1.32B </div> <ul>
<li> May claim Wall Advantage (ATTACKER first).
</ul> </div>

@ -0,0 +1 @@
ATTACKER first, then DEFENDER (A3.6).

@ -0,0 +1,9 @@
<div class="entry"> <div class=B> 6.11B </div> <ul>
<li> Units may Voluntarily Break (A10.41).
</ul> </div>
{{CONTENT_REMOVED|safe}}
<div class="entry"> <div class=B> 6.12B </div> <ul>
<li> Disrupted units in/ADJACENT to enemy Infantry/Cavalry Location (might) Surrender (A19.12) {Interrogation; E2.1}.
</ul> </div>

@ -0,0 +1,7 @@
<div class="entry"> <div class=B> 6.21B </div> <ul>
<li> Conduct all routs (A10.5-.52; A19.12; E1.54; G14.41). <ul>
<li> Leaders may accompany routing units (A10.711).
</ul>
</ul> </div>
{{CONTENT_REMOVED|safe}}

@ -0,0 +1,3 @@
<div class="entry"> <div class=B> 6.31B </div> <ul>
<li> Eliminate all Infantry unable to leave terrain Blaze Locations (B25.4).
</ul> </div>

@ -60,8 +60,8 @@ def test_empty_errata( webapp, webdriver ):
def unload_anno( anno_elem ):
"""Unload an annotation from the UI."""
anno = {}
unload_elem( anno, "caption", find_child(".caption",anno_elem) )
unload_elem( anno, "content", find_child(".content",anno_elem) )
unload_elem( anno, "caption", find_child(".caption",anno_elem), adjust_hilites=True )
unload_elem( anno, "content", find_child(".content",anno_elem), adjust_hilites=True )
img = find_child( "img.icon", anno_elem )
unload_elem( anno, "icon", img )
source = img.get_attribute( "title" )

@ -0,0 +1,289 @@
""" Test the ASOP. """
import os
import json
from asl_rulebook2.webapp.tests.test_search import do_search
from asl_rulebook2.webapp.tests.utils import init_webapp, select_tabbed_page, \
wait_for, wait_for_elem, find_child, find_children, unload_elem, unload_sr_text, get_image_filename, has_class
# ---------------------------------------------------------------------
def test_asop_nav( webdriver, webapp ):
"""Test the ASOP nav."""
# initialize
webapp.control_tests.set_data_dir( "asop" )
init_webapp( webapp, webdriver )
# load the ASOP
fname = os.path.join( os.path.dirname(__file__), "fixtures/asop/asop/index.json" )
with open( fname, "r" ) as fp:
asop_index = json.load( fp )
# check the nav
select_tabbed_page( "#nav", "asop" )
nav = _unload_nav( False )
chapters = asop_index["chapters"]
for chapter_no, chapter in enumerate( chapters ):
chapters[ chapter_no ] = {
key: val for key, val in chapter.items()
if key != "sniper_phase" and not key.startswith("_")
}
chapter.pop( "sniper_phase", None )
assert nav == asop_index["chapters"]
# check the footer
footer = find_child( "#asop-footer" )
assert "Sniper Attacks/Checks are possible" in footer.text
images = [
get_image_filename( c, full=True )
for c in find_children( "img", footer )
]
assert images == [
"/asop/images/attacker.png",
"/asop/images/defender.png",
"/asop/images/both-players.png"
]
# ---------------------------------------------------------------------
def test_asop_content( webdriver, webapp ):
"""Test the ASOP content."""
# initialize
webapp.control_tests.set_data_dir( "asop" )
init_webapp( webapp, webdriver )
select_tabbed_page( "#nav", "asop" )
nav = _unload_nav( True )
def load_asop_file( fname, as_json ):
"""Load an ASOP data file."""
fname = os.path.join( base_dir, fname )
if not os.path.isfile( fname ):
return None
with open( fname, "r" ) as fp:
return json.load( fp ) if as_json else fp.read()
# load the ASOP index
base_dir = os.path.join( os.path.dirname(__file__), "fixtures/asop/asop/" )
asop_index = load_asop_file( "index.json", True )
def check_chapter( chapter_no, callbacks ):
"""Check the specified ASOP chapter."""
# open the chapter
expected_chapter = asop_index["chapters"][ chapter_no ]
nav[ chapter_no ][ "elem" ].click()
expected = len( expected_chapter["sections"] )
wait_for( 2, lambda: len( find_children( "#asop .sections .section" ) ) == expected )
# check the title
title = find_child( "#asop .title" ).text
expected = expected_chapter[ "caption" ]
if expected_chapter.get( "sniper_phase" ):
expected += "\u2020"
assert title == expected
chapter_id = find_child( "#asop" ).get_attribute( "data-chapterid" )
assert chapter_id == expected_chapter["chapter_id"]
# check the preamble
expected_preamble = load_asop_file( "{}-0.html".format( chapter_id ), False )
preamble = find_child( "#asop .preamble" ).text
if preamble:
# NOTE: We only check the first few characters since the data file is processed as a template,
# so it may not match exactly what's in the UI.
assert preamble[0:20] in expected_preamble
else:
assert preamble == ""
# do any chapter-specific checks
func = callbacks.get( "check_{}".format( chapter_id.replace("-","_") ) )
if func:
func()
# check each section (in the combined view)
expected_sections = expected_chapter[ "sections" ]
section_elems = find_children( "#asop .sections .section" )
assert len(expected_sections) == len(section_elems)
for section_no in range( len(expected_sections) ):
# check the section's caption
caption = find_child( ".caption", section_elems[section_no] ).text
assert caption == expected_sections[section_no]["caption"]
# check the section's content
section_id = "{}-{}".format( chapter_id, 1+section_no )
expected_content = load_asop_file( section_id+".html", False )
content = find_child( "ul", section_elems[section_no] ).text
assert content[0:20] in expected_content
# check each individual section
for section_no, nav_section in enumerate( nav[chapter_no]["sections"] ):
# click on the section in the nav pane
find_child( "a", nav_section["elem"] ).click()
# wait for the section's content to appear
expected = expected_sections[ section_no ][ "caption" ]
if expected_chapter.get( "sniper_phase" ):
expected += "\u2020"
wait_for( 2, lambda: find_child("#asop .title").text == expected )
# check the preamble
# NOTE: The preamble is part of the parent chapter, and so should remain unchanged.
preamble = find_child( "#asop .preamble" ).text
if preamble:
assert preamble[0:20] in expected_preamble
else:
assert preamble == ""
# check the section's content
section_id = "{}-{}".format( chapter_id, 1+section_no )
expected_content = load_asop_file( section_id+".html", False )
sections = find_children( "#asop .sections .section" )
assert len(sections) == 1
content = find_child( "ul", sections[0] ).text
assert content[0:20] in expected_content
# do any section-specific checks
func = callbacks.get( "check_{}_section".format( chapter_id.replace("-","_") ) )
if func:
func( 1+section_no )
def check_pre_game(): #pylint: disable=possibly-unused-variable
# check the DYO image in the preamble
assert get_image_filename( find_child("#asop .preamble img.icon"), full=True ) == "/asop/images/dyo.png"
# check the images in the sections
images = set(
get_image_filename( c, full=True )
for c in find_children( "#asop .sections .section img.icon" )
)
assert images == set( [
"/asop/images/dyo.png", "/asop/images/both-players.png"
] )
def check_pre_game_section( section_no ): #pylint: disable=possibly-unused-variable
if section_no == 1:
# check the DYO image in the preamble
assert get_image_filename( find_child("#asop .preamble img.icon"), full=True ) == "/asop/images/dyo.png"
def check_rally(): #pylint: disable=possibly-unused-variable
# check the EXC block in the preamble
assert has_class( find_child( "#asop .preamble span" ), "exc" )
def check_movement(): #pylint: disable=possibly-unused-variable
# there should be 3 EXC blocks
elems = [
c for c in find_children( "#asop .sections .section span" )
if has_class( c, "exc" )
]
elems = set( e.text for e in elems ) # why are we seeing everything twice?! :-/
assert len(elems) == 3
def check_movement_section( section_no ): #pylint: disable=possibly-unused-variable
# check an EXC block
if section_no == 5:
assert has_class( find_child( "#asop .sections .section span" ), "exc" )
# check each chapter
for i in range( 0, 8+1 ):
check_chapter( i, locals() )
# check error handling
nav[ 9 ][ "elem" ].click()
sections = find_children( "#asop .sections .section" )
assert len(sections) == 0
# ---------------------------------------------------------------------
def test_asop_entries( webdriver, webapp ):
"""Test searching for individual ASOP entries."""
# initialize
webapp.control_tests.set_data_dir( "asop" )
init_webapp( webapp, webdriver )
def do_test( query_string, expected ):
# do the search
results = do_search( query_string )
# check the search results
assert len(results) == len(expected)
for i in range( len(results) ):
assert expected[i][0] in results[i]["content"]
assert expected[i][1]in results[i]["content"]
# make sure we can click through to the ASOP
sr_elems = find_children( "#search-results .asop-entry-sr" )
for sr_no, sr_elem in enumerate( sr_elems ):
# click on the search result
find_child( ".caption", sr_elem ).click()
wait_for_elem( 2, "#asop" )
# check the contents of the ASOP popup
entries = find_children( "#asop .sections .section .entry" )
assert len(entries) == 1
assert expected[sr_no][0] in unload_sr_text( entries[0] )
# check the nav pane
panes = [
c for c in find_children( "#nav .accordian-pane" )
if find_child( ".entries", c ).value_of_css_property( "display" ) != "none"
]
assert len(panes) == 1
assert panes[0].get_attribute( "data-chapterid" ) == expected[sr_no][2]
# return back to the Search nav pane
select_tabbed_page( "#nav", "search" )
# do the tests
do_test( "napalm", [
[ "2.11A", "checking for any ((Napalm)) terrain-Blaze/weapon", "prep-fire" ]
] )
do_test( "reverse", [
[ "2.15A", "((Reverse)) Slopes", "prep-fire" ],
[ "4.14D", "((Reverse)) Slopes", "defensive-fire" ],
] )
do_test( '"mop up"', [
[ "2.21A", "((Mop Up)) (A12.153)", "prep-fire" ]
] )
do_test( '"crew become TI"', [
[ "2.23A", "it and ((crew become TI))", "prep-fire" ],
[ "4.22D", "it and ((crew become TI))", "defensive-fire" ],
] )
# search for something that is not part of an "entry" div
results = do_search( "porpoise" )
assert results is None
# ---------------------------------------------------------------------
def _unload_nav( include_elems ):
"""Unload the ASOP nav."""
chapters = []
for chapter_elem in find_children( "#nav .accordian-pane" ):
# unload the next chapter
chapter = {
"chapter_id": chapter_elem.get_attribute( "data-chapterid" ),
}
unload_elem( chapter, "caption", find_child(".title",chapter_elem) )
if include_elems:
chapter[ "elem" ] = chapter_elem
# unload the chapter's sections
chapter_elem.click() # nb: panes must be open to unload their sections :-/
sections = []
for section_elem in find_children( ".entry", chapter_elem ):
sections.append( {
"caption": section_elem.text,
} )
if include_elems:
sections[-1]["elem"] = section_elem
if sections:
chapter[ "sections" ] = sections
chapters.append( chapter )
return chapters

@ -1,8 +1,8 @@
""" Test Q+A. """
from asl_rulebook2.webapp.tests.utils import init_webapp, \
find_child, find_children, wait_for_elem, get_image_filename
from asl_rulebook2.webapp.tests.test_search import do_search, unload_elem, get_elem_text
find_child, find_children, wait_for_elem, get_image_filename, unload_elem, unload_sr_text
from asl_rulebook2.webapp.tests.test_search import do_search
# ---------------------------------------------------------------------
@ -135,7 +135,7 @@ def unload_qa( qa_elem ):
qa_entry = {}
# unload the top-level fields
unload_elem( qa_entry, "caption", find_child(".caption",qa_elem) )
unload_elem( qa_entry, "caption", find_child(".caption",qa_elem), adjust_hilites=True )
# unload each content node
qa_content = []
@ -145,15 +145,15 @@ def unload_qa( qa_elem ):
content = {
"icon": get_image_filename( find_child( "img.icon", content_elem ) ),
}
unload_elem( content, "question", find_child(".question",content_elem) )
unload_elem( content, "question", find_child(".question",content_elem), adjust_hilites=True )
unload_elem( content, "image", find_child("img.imageZoom",content_elem) )
unload_elem( content, "see_other", find_child(".see-other",content_elem) )
unload_elem( content, "see_other", find_child(".see-other",content_elem), adjust_hilites=True )
# unload the answers (if any)
answers = []
for answer_elem in find_children( ".answer", content_elem ):
answers.append( [
get_elem_text( answer_elem ),
unload_sr_text( answer_elem ),
find_child( "img.icon", answer_elem ).get_attribute( "title" ),
] )
if answers:

@ -1,15 +1,13 @@
""" Test search. """
import re
import logging
from selenium.webdriver.common.keys import Keys
from asl_rulebook2.utils import strip_html
from asl_rulebook2.webapp.search import load_search_config, _make_fts_query_string
from asl_rulebook2.webapp.startup import StartupMsgs
from asl_rulebook2.webapp.tests.utils import init_webapp, select_tabbed_page, get_curr_target, get_classes, \
wait_for, find_child, find_children, get_image_filename
wait_for, find_child, find_children, unload_elem, unload_sr_text
# ---------------------------------------------------------------------
@ -347,7 +345,7 @@ def _unload_search_results():
return
ruleids = []
for elem in find_children( ".ruleid", parent ):
ruleid = get_elem_text( elem )
ruleid = unload_sr_text( elem )
assert ruleid.startswith( "[" ) and ruleid.endswith( "]" )
ruleids.append( ruleid[1:-1] )
if ruleids:
@ -360,7 +358,7 @@ def _unload_search_results():
rulerefs = []
for elem in find_children( "li", parent ):
ruleref = {}
unload_elem( ruleref, "caption", find_child(".caption",elem) )
unload_elem( ruleref, "caption", find_child(".caption",elem), adjust_hilites=True )
unload_ruleids( ruleref, "ruleids", elem )
rulerefs.append( ruleref )
if rulerefs:
@ -369,9 +367,9 @@ def _unload_search_results():
def unload_index_sr( sr ): #pylint: disable=possibly-unused-variable
"""Unload an "index" search result."""
result = {}
unload_elem( result, "title", find_child("span.title",sr) )
unload_elem( result, "subtitle", find_child(".subtitle",sr) )
unload_elem( result, "content", find_child(".content",sr) )
unload_elem( result, "title", find_child("span.title",sr), adjust_hilites=True )
unload_elem( result, "subtitle", find_child(".subtitle",sr), adjust_hilites=True )
unload_elem( result, "content", find_child(".content",sr), adjust_hilites=True )
if unload_elem( result, "see_also", find_child(".see-also",sr) ):
assert result["see_also"].startswith( "See also:" )
result["see_also"] = [ s.strip() for s in result["see_also"][9:].split( "," ) ]
@ -389,49 +387,30 @@ def _unload_search_results():
from asl_rulebook2.webapp.tests.test_annotations import unload_anno
return unload_anno( sr )
def unload_asop_entry_sr( sr ): #pylint: disable=possibly-unused-variable
"""Unload an "ASOP entry" search result."""
result = {}
unload_elem( result, "caption", find_child(".caption",sr), adjust_hilites=True )
unload_elem( result, "content", find_child(".content",sr), adjust_hilites=True )
return result
# unload the search results
results = []
for sr in find_children( "#search-results .sr"):
classes = get_classes( sr )
classes.remove( "sr" )
classes = [ c for c in classes if c in ["index-sr","qa","anno"] ]
classes = [ c for c in classes if c in ["index-sr","qa","anno","asop-entry-sr"] ]
assert len(classes) == 1
sr_type = classes[0]
if sr_type.endswith( "-sr" ):
sr_type = sr_type[:-3]
func = locals()[ "unload_{}_sr".format( sr_type ) ]
func = locals()[ "unload_{}_sr".format( sr_type.replace("-","_") ) ]
sr = func( sr )
sr["sr_type"] = sr_type
results.append( sr )
return results
def unload_elem( save_loc, key, elem ):
"""Unload a single element."""
if not elem:
return False
if elem.tag_name in ("div", "span"):
val = get_elem_text( elem )
elif elem.tag_name == "img":
val = get_image_filename( elem )
else:
assert False, "Unknown element type: " + elem.tag_name
return False
if not val:
return False
save_loc[ key ] = val
return True
def get_elem_text( elem ):
"""Get the element's text content."""
val = elem.get_attribute( "innerHTML" )
# change how highlighted content is represented
matches = list( re.finditer( r'<span class="hilite">(.*?)</span>', val ) )
for mo in reversed(matches):
val = val[:mo.start()] + "((" + mo.group(1) + "))" + val[mo.end():]
# remove HTML tags
return strip_html( val ).strip()
# ---------------------------------------------------------------------
def _is_expanded_rulerefs( sr_elem ):

@ -4,11 +4,13 @@ import sys
import os
import urllib.request
import json
import re
import uuid
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from asl_rulebook2.utils import strip_html
from asl_rulebook2.webapp import tests as webapp_tests
_webapp = None
@ -160,12 +162,44 @@ def has_class( elem, class_name ):
"""Check if an element has a specified CSS class."""
return class_name in get_classes( elem )
def get_image_filename( elem ):
def unload_elem( save_loc, key, elem, adjust_hilites=False ):
"""Unload a single element."""
if not elem:
return False
if elem.tag_name in ("div", "span"):
val = unload_sr_text( elem ) if adjust_hilites else elem.text
elif elem.tag_name == "img":
val = get_image_filename( elem )
else:
assert False, "Unknown element type: " + elem.tag_name
return False
if not val:
return False
save_loc[ key ] = val
return True
def unload_sr_text( elem ):
"""Unload a text value that is part of a search result."""
val = elem.get_attribute( "innerHTML" )
# change how highlighted content is represented
matches = list( re.finditer( r'<span class="hilite">(.*?)</span>', val ) )
for mo in reversed(matches):
val = val[:mo.start()] + "((" + mo.group(1) + "))" + val[mo.end():]
# remove HTML tags
val = strip_html( val ).strip()
return val
def get_image_filename( elem, full=False ):
"""Get the filename of an <img> element."""
if elem is None:
return None
assert elem.tag_name == "img"
return os.path.basename( elem.get_attribute( "src" ) )
src = elem.get_attribute( "src" )
if full:
src = re.sub( r"^http://[^/]+", "", src )
else:
src = os.path.basename( src )
return re.sub( r"/+", "/", src )
# ---------------------------------------------------------------------

Loading…
Cancel
Save